diff --git a/changelog.md b/changelog.md index 9cc9662..4f2ba83 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2025-09-12 - 5.2.0 - feat(smartjson) +Implement stableOneWayStringify: deterministic, cycle-safe JSON for hashing/comparisons; update docs and tests + +- Implement stableOneWayStringify in ts/index.ts: produces deterministic JSON, encodes buffers, replaces circular references with "__cycle__" and marks unserializable values as "__unserializable__". +- Update README: add documentation and examples for stableOneWayStringify, clarify JSONL wording, and update performance/migration notes. +- Add unit test to verify stableOneWayStringify handles circular references without throwing. +- Add .claude/settings.local.json (local settings/config) as part of the change set. + ## 2025-09-12 - 5.1.0 - feat(smartjson) Add JSONL stringify and ordering comparator; fix empty buffer handling; refactor Smartjson folding/enfolding diff --git a/readme.md b/readme.md index 966dfbb..bb61b81 100644 --- a/readme.md +++ b/readme.md @@ -21,9 +21,10 @@ pnpm add @push.rocks/smartjson ✨ **Type-Safe JSON Operations** - Full TypeScript support with proper typing 🎯 **Class Instance Serialization** - Fold and unfold class instances to/from JSON 🔐 **Buffer & Binary Support** - Seamless handling of Buffers and Typed Arrays -📊 **JSON Lines Support** - Parse and compare JSONL data streams +📊 **JSON Lines Support** - Parse, stringify and compare JSONL data streams 🎨 **Pretty Printing** - Beautiful formatted JSON output ⚡ **Stable Stringification** - Consistent key ordering for reliable comparisons +♻️ **Circular Reference Handling** - Safe one-way stringify for objects with cycles 🔍 **Deep Equality Checks** - Compare complex objects and JSON structures 🌐 **Base64 Encoding** - Built-in base64 JSON encoding/decoding @@ -67,6 +68,40 @@ const prettyJson = smartjson.stringifyPretty({ }); ``` +### Safe One-Way Stringification (New in v5.1.0) + +Handle circular references and unserializable values safely for hashing and comparisons: + +```typescript +// Create objects with circular references +const objA = { name: 'A' }; +const objB = { name: 'B', ref: objA }; +objA['ref'] = objB; // Circular reference! + +// Normal stringify would throw, but stableOneWayStringify handles it +const safeJson = smartjson.stableOneWayStringify(objA); +// Circular references are replaced with "__cycle__" + +// Perfect for object hashing +const hash1 = crypto.createHash('sha256') + .update(smartjson.stableOneWayStringify(complexObject)) + .digest('hex'); + +// Handles unserializable values gracefully +const objWithGetter = { + normal: 'value', + get throwing() { throw new Error('Cannot serialize!'); } +}; +const safe = smartjson.stableOneWayStringify(objWithGetter); +// Throwing getters are replaced with "__unserializable__" + +// Custom key ordering for consistent hashes +const ordered = smartjson.stableOneWayStringify( + { z: 1, a: 2 }, + ['a', 'z'] // Specify key order +); +``` + ### Base64 JSON Encoding Encode JSON data as base64 for safe transmission: @@ -311,6 +346,7 @@ class AppConfig extends Smartjson { - `parse(jsonString: string): any` - Parse JSON with automatic buffer handling - `stringify(obj: any, simpleOrderArray?: string[], options?: Options): string` - Convert to JSON with stable ordering +- `stableOneWayStringify(obj: any, simpleOrderArray?: string[], options?: Options): string` - Safe stringify with circular reference handling (one-way, for hashing/comparison) - `stringifyPretty(obj: any): string` - Pretty print JSON with 2-space indentation - `stringifyBase64(obj: any): string` - Encode JSON as base64 - `parseBase64(base64String: string): any` - Decode base64 JSON @@ -336,7 +372,8 @@ class AppConfig extends Smartjson { 2. **Enable pretty printing** only for debugging (it's slower) 3. **Cache base64 encodings** when repeatedly sending the same data 4. **Use JSON Lines** for streaming large datasets -5. **Avoid circular references** in objects being serialized +5. **Use stableOneWayStringify** for objects with circular references (for hashing/caching) +6. **Prefer regular stringify** for round-trip serialization without cycles ## Migration Guide diff --git a/test/test.both.ts b/test/test.both.ts index 66e89ae..ba03445 100644 --- a/test/test.both.ts +++ b/test/test.both.ts @@ -104,4 +104,15 @@ tap.test('should respect simpleOrderArray comparator', async () => { expect(ordered.indexOf('"a"')).toBeLessThan(ordered.indexOf('"c"')); }); +tap.test('stableOneWayStringify should handle circular references without throwing', async () => { + const a: any = { name: 'x' }; + a.self = a; + const b: any = { name: 'x' }; + b.self = b; + const s1 = smartjson.stableOneWayStringify(a); + const s2 = smartjson.stableOneWayStringify(b); + expect(typeof s1).toEqual('string'); + expect(s1).toEqual(s2); +}); + tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 7749be9..860ef3e 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartjson', - version: '5.1.0', + version: '5.2.0', description: 'A library for handling typed JSON data, providing functionalities for parsing, stringifying, and working with JSON objects, including support for encoding and decoding buffers.' } diff --git a/ts/index.ts b/ts/index.ts index 63cb09a..9f0f8c6 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -26,6 +26,69 @@ export const stringifyJsonL = (items: any[]): string => { return items.map((item) => stringify(item)).join('\n'); } +/** + * stableOneWayStringify + * - Produces a stable, deterministic JSON string + * - Handles circular references without throwing (replaces cycles) + * - Safe for hashing/comparisons ("one-way"; not intended for round-trips) + */ +export const stableOneWayStringify = ( + objArg: any, + simpleOrderArray?: string[], + optionsArg: plugins.IStableJsonTypes['Options'] = {} +): string => { + // Prepare object without throwing on circular references, and encode buffers + const visited = new WeakSet(); + const sanitize = (val: any): any => { + // primitives + if (val === null || typeof val !== 'object') { + return val; + } + // Encode buffers/typed arrays via existing replacer + const replaced = (bufferhandling.replacer as any)('', val); + if (replaced && replaced.type === 'EncodedBuffer' && typeof replaced.data === 'string') { + return replaced; + } + // Handle circular references + if (visited.has(val)) { + return '__cycle__'; + } + visited.add(val); + // Arrays + if (Array.isArray(val)) { + return val.map((item) => sanitize(item)); + } + // Plain objects and class instances: copy enumerable own props + const out: Record = {}; + for (const key of Object.keys(val)) { + try { + out[key] = sanitize((val as any)[key]); + } catch (e) { + // In case of getters throwing or non-serializable, mark + out[key] = '__unserializable__'; + } + } + return out; + }; + + const obj = sanitize(objArg); + const options: plugins.IStableJsonTypes['Options'] = { + ...optionsArg, + cycles: true, + }; + if (simpleOrderArray && !options.cmp) { + const order = new Map(); + simpleOrderArray.forEach((key, idx) => order.set(key, idx)); + options.cmp = (a, b) => { + const aIdx = order.has(a.key) ? (order.get(a.key) as number) : Number.POSITIVE_INFINITY; + const bIdx = order.has(b.key) ? (order.get(b.key) as number) : Number.POSITIVE_INFINITY; + if (aIdx !== bIdx) return aIdx - bIdx; + return a.key < b.key ? -1 : a.key > b.key ? 1 : 0; + }; + } + return plugins.stableJson(obj, options); +} + /** * * @param objArg