Compare commits
4 Commits
Author | SHA1 | Date | |
---|---|---|---|
acf8cea099 | |||
bd67581b75 | |||
374a8e411a | |||
fe8b5ce7c0 |
18
changelog.md
18
changelog.md
@@ -1,5 +1,23 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
|
||||||
|
- Export stringifyJsonL(items: any[]) and add README documentation for JSONL stringification
|
||||||
|
- stringify(obj, simpleOrderArray) now derives a stable-json comparator from simpleOrderArray when no custom cmp is provided, ensuring predictable key ordering
|
||||||
|
- Fix buffer handling: empty Uint8Array values are preserved (no longer serialized to an empty string) and encoding/decoding logic improved
|
||||||
|
- Refactor Smartjson.enfoldFromObject to safely use saveableProperties and avoid repeated property access
|
||||||
|
- Simplify Smartjson.foldToObject to delegate to an internal foldToObjectInternal with cycle detection and correct nested instance handling
|
||||||
|
- Add unit tests for empty buffers, JSONL parse/stringify, deepEqualJsonLStrings, and simpleOrderArray comparator behavior
|
||||||
|
|
||||||
## 2025-09-12 - 5.0.21 - fix(Smartjson)
|
## 2025-09-12 - 5.0.21 - fix(Smartjson)
|
||||||
Cross-platform buffer/base64 handling, safer folding with cycle detection, parsing fixes, docs and dependency updates
|
Cross-platform buffer/base64 handling, safer folding with cycle detection, parsing fixes, docs and dependency updates
|
||||||
|
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartjson",
|
"name": "@push.rocks/smartjson",
|
||||||
"version": "5.0.21",
|
"version": "5.2.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"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.",
|
"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.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
|
49
readme.md
49
readme.md
@@ -21,9 +21,10 @@ pnpm add @push.rocks/smartjson
|
|||||||
✨ **Type-Safe JSON Operations** - Full TypeScript support with proper typing
|
✨ **Type-Safe JSON Operations** - Full TypeScript support with proper typing
|
||||||
🎯 **Class Instance Serialization** - Fold and unfold class instances to/from JSON
|
🎯 **Class Instance Serialization** - Fold and unfold class instances to/from JSON
|
||||||
🔐 **Buffer & Binary Support** - Seamless handling of Buffers and Typed Arrays
|
🔐 **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
|
🎨 **Pretty Printing** - Beautiful formatted JSON output
|
||||||
⚡ **Stable Stringification** - Consistent key ordering for reliable comparisons
|
⚡ **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
|
🔍 **Deep Equality Checks** - Compare complex objects and JSON structures
|
||||||
🌐 **Base64 Encoding** - Built-in base64 JSON encoding/decoding
|
🌐 **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
|
### Base64 JSON Encoding
|
||||||
|
|
||||||
Encode JSON data as base64 for safe transmission:
|
Encode JSON data as base64 for safe transmission:
|
||||||
@@ -99,6 +134,13 @@ const jsonLines = `{"event":"start","time":1234}
|
|||||||
const events = smartjson.parseJsonL(jsonLines);
|
const events = smartjson.parseJsonL(jsonLines);
|
||||||
// Result: Array of parsed objects
|
// Result: Array of parsed objects
|
||||||
|
|
||||||
|
// Produce JSONL from objects
|
||||||
|
const jsonlOut = smartjson.stringifyJsonL([
|
||||||
|
{ event: 'start', time: 1234 },
|
||||||
|
{ event: 'data', value: 42 },
|
||||||
|
{ event: 'end', time: 5678 }
|
||||||
|
]);
|
||||||
|
|
||||||
// Compare JSON Lines data
|
// Compare JSON Lines data
|
||||||
const jsonL1 = `{"id":1}\n{"id":2}`;
|
const jsonL1 = `{"id":1}\n{"id":2}`;
|
||||||
const jsonL2 = `{"id":1}\n{"id":2}`;
|
const jsonL2 = `{"id":1}\n{"id":2}`;
|
||||||
@@ -304,10 +346,12 @@ class AppConfig extends Smartjson {
|
|||||||
|
|
||||||
- `parse(jsonString: string): any` - Parse JSON with automatic buffer handling
|
- `parse(jsonString: string): any` - Parse JSON with automatic buffer handling
|
||||||
- `stringify(obj: any, simpleOrderArray?: string[], options?: Options): string` - Convert to JSON with stable ordering
|
- `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
|
- `stringifyPretty(obj: any): string` - Pretty print JSON with 2-space indentation
|
||||||
- `stringifyBase64(obj: any): string` - Encode JSON as base64
|
- `stringifyBase64(obj: any): string` - Encode JSON as base64
|
||||||
- `parseBase64(base64String: string): any` - Decode base64 JSON
|
- `parseBase64(base64String: string): any` - Decode base64 JSON
|
||||||
- `parseJsonL(jsonLinesString: string): any[]` - Parse JSON Lines format
|
- `parseJsonL(jsonLinesString: string): any[]` - Parse JSON Lines format
|
||||||
|
- `stringifyJsonL(items: any[]): string` - Stringify array to JSON Lines
|
||||||
- `deepEqualObjects(obj1: any, obj2: any): boolean` - Deep comparison of objects
|
- `deepEqualObjects(obj1: any, obj2: any): boolean` - Deep comparison of objects
|
||||||
- `deepEqualJsonLStrings(jsonL1: string, jsonL2: string): boolean` - Compare JSON Lines strings
|
- `deepEqualJsonLStrings(jsonL1: string, jsonL2: string): boolean` - Compare JSON Lines strings
|
||||||
|
|
||||||
@@ -328,7 +372,8 @@ class AppConfig extends Smartjson {
|
|||||||
2. **Enable pretty printing** only for debugging (it's slower)
|
2. **Enable pretty printing** only for debugging (it's slower)
|
||||||
3. **Cache base64 encodings** when repeatedly sending the same data
|
3. **Cache base64 encodings** when repeatedly sending the same data
|
||||||
4. **Use JSON Lines** for streaming large datasets
|
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
|
## Migration Guide
|
||||||
|
|
||||||
|
@@ -72,4 +72,47 @@ tap.test('should work with buffers', async () => {
|
|||||||
expect(text).toEqual('hello');
|
expect(text).toEqual('hello');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('should handle empty buffers', async () => {
|
||||||
|
const someObject = { empty: new Uint8Array([]) };
|
||||||
|
const json = smartjson.stringify(someObject);
|
||||||
|
const parsed = smartjson.parse(json);
|
||||||
|
expect(parsed.empty).toBeInstanceOf(Uint8Array);
|
||||||
|
expect(parsed.empty.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should parse and stringify JSONL', async () => {
|
||||||
|
const items = [
|
||||||
|
{ id: 1, name: 'a' },
|
||||||
|
{ id: 2, name: 'b' }
|
||||||
|
];
|
||||||
|
const jsonl = smartjson.stringifyJsonL(items);
|
||||||
|
const parsed = smartjson.parseJsonL(jsonl);
|
||||||
|
expect(parsed).toEqual(items);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should deep-compare JSONL strings', async () => {
|
||||||
|
const a = '{"id":2,"name":"b"}\n{"id":1,"name":"a"}';
|
||||||
|
const b = '{"id":2,"name":"b"}\n{"id":1,"name":"a"}';
|
||||||
|
expect(smartjson.deepEqualJsonLStrings(a, b)).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should respect simpleOrderArray comparator', async () => {
|
||||||
|
const obj = { c: 3, a: 1, b: 2 };
|
||||||
|
const ordered = smartjson.stringify(obj, ['b', 'a']);
|
||||||
|
// ensure keys b, a come before c
|
||||||
|
expect(ordered.indexOf('"b"')).toBeLessThan(ordered.indexOf('"a"'));
|
||||||
|
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();
|
tap.start();
|
||||||
|
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartjson',
|
name: '@push.rocks/smartjson',
|
||||||
version: '5.0.21',
|
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.'
|
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.'
|
||||||
}
|
}
|
||||||
|
@@ -52,11 +52,7 @@ const replacer: TParseReplacer = (key, value) => {
|
|||||||
|
|
||||||
// Handle IBufferLike objects with a .data property
|
// Handle IBufferLike objects with a .data property
|
||||||
if ('data' in value && isArray(value.data)) {
|
if ('data' in value && isArray(value.data)) {
|
||||||
if (value.data.length > 0) {
|
bufferData = new Uint8Array(value.data);
|
||||||
bufferData = new Uint8Array(value.data);
|
|
||||||
} else {
|
|
||||||
return ''; // Return empty string for empty data arrays
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Handle Uint8Array directly
|
// Handle Uint8Array directly
|
||||||
else if (value instanceof Uint8Array) {
|
else if (value instanceof Uint8Array) {
|
||||||
|
111
ts/index.ts
111
ts/index.ts
@@ -22,6 +22,73 @@ export const parseJsonL = (jsonlData: string): JsonObject[] => {
|
|||||||
return parsedData;
|
return parsedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<object>();
|
||||||
|
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<string, any> = {};
|
||||||
|
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<string, number>();
|
||||||
|
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
|
* @param objArg
|
||||||
@@ -34,7 +101,20 @@ export const stringify = (
|
|||||||
): string => {
|
): string => {
|
||||||
const bufferedJson = bufferhandling.stringify(objArg);
|
const bufferedJson = bufferhandling.stringify(objArg);
|
||||||
objArg = JSON.parse(bufferedJson);
|
objArg = JSON.parse(bufferedJson);
|
||||||
let returnJson = plugins.stableJson(objArg, optionsArg);
|
// derive a simple comparator from simpleOrderArray if provided and no custom cmp supplied
|
||||||
|
let options = { ...optionsArg };
|
||||||
|
if (simpleOrderArray && !options.cmp) {
|
||||||
|
const order = new Map<string, number>();
|
||||||
|
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;
|
||||||
|
// fallback to lexicographic order for stable behavior
|
||||||
|
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let returnJson = plugins.stableJson(objArg, options);
|
||||||
return returnJson;
|
return returnJson;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,9 +142,10 @@ export class Smartjson {
|
|||||||
*/
|
*/
|
||||||
public static enfoldFromObject<T extends typeof Smartjson>(this: T, objectArg: any): InstanceType<T> {
|
public static enfoldFromObject<T extends typeof Smartjson>(this: T, objectArg: any): InstanceType<T> {
|
||||||
const newInstance = new this() as InstanceType<T>;
|
const newInstance = new this() as InstanceType<T>;
|
||||||
|
const saveables: string[] = (newInstance as any).saveableProperties || [];
|
||||||
for (const keyName in objectArg) {
|
for (const keyName in objectArg) {
|
||||||
if (newInstance.saveableProperties.indexOf(keyName) !== -1) {
|
if (saveables.indexOf(keyName) !== -1) {
|
||||||
newInstance[keyName] = objectArg[keyName];
|
(newInstance as any)[keyName] = objectArg[keyName];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newInstance;
|
return newInstance;
|
||||||
@@ -88,26 +169,9 @@ export class Smartjson {
|
|||||||
* folds a class into an object
|
* folds a class into an object
|
||||||
*/
|
*/
|
||||||
public foldToObject() {
|
public foldToObject() {
|
||||||
const newFoldedObject: { [key: string]: any } = {};
|
|
||||||
const trackSet = new Set<Smartjson>();
|
const trackSet = new Set<Smartjson>();
|
||||||
const foldValue = (val: any): any => {
|
trackSet.add(this);
|
||||||
if (val instanceof Smartjson) {
|
return this.foldToObjectInternal(trackSet);
|
||||||
if (trackSet.has(val)) {
|
|
||||||
throw new Error('cycle detected');
|
|
||||||
}
|
|
||||||
trackSet.add(val);
|
|
||||||
return val.foldToObjectInternal(trackSet);
|
|
||||||
}
|
|
||||||
if (Array.isArray(val)) {
|
|
||||||
return val.map((item) => foldValue(item));
|
|
||||||
}
|
|
||||||
return plugins.lodashCloneDeep(val);
|
|
||||||
};
|
|
||||||
for (const keyName of this.saveableProperties) {
|
|
||||||
const value = this[keyName];
|
|
||||||
newFoldedObject[keyName] = foldValue(value);
|
|
||||||
}
|
|
||||||
return newFoldedObject;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private foldToObjectInternal(trackSet: Set<Smartjson>) {
|
private foldToObjectInternal(trackSet: Set<Smartjson>) {
|
||||||
@@ -125,7 +189,8 @@ export class Smartjson {
|
|||||||
}
|
}
|
||||||
return plugins.lodashCloneDeep(val);
|
return plugins.lodashCloneDeep(val);
|
||||||
};
|
};
|
||||||
for (const keyName of this.saveableProperties) {
|
const props: string[] = (this as any).saveableProperties || [];
|
||||||
|
for (const keyName of props) {
|
||||||
const value = this[keyName];
|
const value = this[keyName];
|
||||||
result[keyName] = foldValue(value);
|
result[keyName] = foldValue(value);
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user