Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions ably.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2606,6 +2606,9 @@ export interface LiveMapPathObject<T extends Record<string, Value> = Record<stri
/**
* Get a JavaScript object representation of the map at this path.
* Binary values are returned as base64-encoded strings.
* Cyclic references are handled through memoization, returning shared compacted
* object references for previously visited objects. This means the value returned
* from `compact()` cannot be directly JSON-stringified if the object may contain cycles.
*
* If the path does not resolve to any specific instance, returns `undefined`.
*
Expand Down Expand Up @@ -2756,6 +2759,11 @@ export interface AnyPathObject
* Get a JavaScript object representation of the object at this path.
* Binary values are returned as base64-encoded strings.
*
* When compacting a {@link LiveMap}, cyclic references are handled through
* memoization, returning shared compacted object references for previously
* visited objects. This means the value returned from `compact()` cannot be
* directly JSON-stringified if the object may contain cycles.
*
* If the path does not resolve to any specific entry, returns `undefined`.
*
* @experimental
Expand Down Expand Up @@ -2859,6 +2867,9 @@ export interface LiveMapBatchContext<T extends Record<string, Value> = Record<st
/**
* Get a JavaScript object representation of the map instance.
* Binary values are returned as base64-encoded strings.
* Cyclic references are handled through memoization, returning shared compacted
* object references for previously visited objects. This means the value returned
* from `compact()` cannot be directly JSON-stringified if the object may contain cycles.
*
* If the underlying instance's value is not of the expected type at runtime, returns `undefined`.
*
Expand Down Expand Up @@ -2995,6 +3006,11 @@ export interface AnyBatchContext extends BatchContextBase, AnyBatchContextCollec
* Get a JavaScript object representation of the object instance.
* Binary values are returned as base64-encoded strings.
*
* When compacting a {@link LiveMap}, cyclic references are handled through
* memoization, returning shared compacted object references for previously
* visited objects. This means the value returned from `compact()` cannot be
* directly JSON-stringified if the object may contain cycles.
*
* If the underlying instance's value is not of the expected type at runtime, returns `undefined`.
*
* @experimental
Expand Down Expand Up @@ -3463,6 +3479,9 @@ export interface LiveMapInstance<T extends Record<string, Value> = Record<string
/**
* Get a JavaScript object representation of the map instance.
* Binary values are returned as base64-encoded strings.
* Cyclic references are handled through memoization, returning shared compacted
* object references for previously visited objects. This means the value returned
* from `compact()` cannot be directly JSON-stringified if the object may contain cycles.
*
* If the underlying instance's value is not of the expected type at runtime, returns `undefined`.
*
Expand Down Expand Up @@ -3604,6 +3623,11 @@ export interface AnyInstance<T extends Value> extends InstanceBase<T>, AnyInstan
* Get a JavaScript object representation of the object instance.
* Binary values are returned as base64-encoded strings.
*
* When compacting a {@link LiveMap}, cyclic references are handled through
* memoization, returning shared compacted object references for previously
* visited objects. This means the value returned from `compact()` cannot be
* directly JSON-stringified if the object may contain cycles.
*
* If the underlying instance's value is not of the expected type at runtime, returns `undefined`.
*
* @experimental
Expand Down
15 changes: 13 additions & 2 deletions src/plugins/objects/livemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,17 +492,28 @@ export class LiveMap<T extends Record<string, API.Value> = Record<string, API.Va
/**
* Returns a plain JavaScript object representation of this LiveMap.
* LiveMap values are recursively compacted using their own compact methods.
* Compacted LiveMaps are memoized to handle cyclic references.
* Buffers are converted to base64 strings.
*
* @internal
*/
compact(): API.CompactedValue<API.LiveMap<T>> {
compact(memoizedObjects?: Map<string, Record<string, any>>): API.CompactedValue<API.LiveMap<T>> {
const memo = memoizedObjects ?? new Map<string, Record<string, any>>();
const result: Record<keyof T, any> = {} as Record<keyof T, any>;

// Memoize the compacted result to handle circular references
memo.set(this.getObjectId(), result);

// Use public entries() method to ensure we only include publicly exposed properties
for (const [key, value] of this.entries()) {
if (value instanceof LiveMap) {
result[key] = value.compact();
if (memo.has(value.getObjectId())) {
// If the LiveMap has already been compacted, just reference it to avoid infinite loops
result[key] = memo.get(value.getObjectId());
} else {
// Otherwise, compact it
result[key] = value.compact(memo);
}
continue;
}

Expand Down
138 changes: 138 additions & 0 deletions test/realtime/objects.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5101,6 +5101,75 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function
},
},

{
description: 'PathObject.compact() handles cyclic references',
action: async (ctx) => {
const { objectsHelper, channelName, entryInstance, entryPathObject } = ctx;

// Create a structure with cyclic references using REST API (realtime does not allow referencing objects by id):
// root -> map1 -> map2 -> map1 (back reference)

const { objectId: map1Id } = await objectsHelper.operationRequest(
channelName,
objectsHelper.mapCreateRestOp({ data: { foo: { string: 'bar' } } }),
);
const { objectId: map2Id } = await objectsHelper.operationRequest(
channelName,
objectsHelper.mapCreateRestOp({ data: { baz: { number: 42 } } }),
);

// Set up the cyclic references
let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'map1');
await objectsHelper.operationRequest(
channelName,
objectsHelper.mapSetRestOp({
objectId: 'root',
key: 'map1',
value: { objectId: map1Id },
}),
);
await keyUpdatedPromise;

keyUpdatedPromise = waitForMapKeyUpdate(entryInstance.get('map1'), 'map2');
await objectsHelper.operationRequest(
channelName,
objectsHelper.mapSetRestOp({
objectId: map1Id,
key: 'map2',
value: { objectId: map2Id },
}),
);
await keyUpdatedPromise;

keyUpdatedPromise = waitForMapKeyUpdate(entryInstance.get('map1').get('map2'), 'map1BackRef');
await objectsHelper.operationRequest(
channelName,
objectsHelper.mapSetRestOp({
objectId: map2Id,
key: 'map1BackRef',
value: { objectId: map1Id },
}),
);
await keyUpdatedPromise;

// Test that compact() handles cyclic references correctly
const compactEntry = entryPathObject.compact();

expect(compactEntry).to.exist;
expect(compactEntry.map1).to.exist;
expect(compactEntry.map1.foo).to.equal('bar', 'Check primitive value is preserved');
expect(compactEntry.map1.map2).to.exist;
expect(compactEntry.map1.map2.baz).to.equal(42, 'Check nested primitive value is preserved');
expect(compactEntry.map1.map2.map1BackRef).to.exist;

// The back reference should point to the same object reference
expect(compactEntry.map1.map2.map1BackRef).to.equal(
compactEntry.map1,
'Check cyclic reference returns the same memoized result object',
);
},
},

{
description: 'PathObject.batch() passes RootBatchContext to its batch function',
action: async (ctx) => {
Expand Down Expand Up @@ -6088,6 +6157,75 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function
},
},

{
description: 'DefaultInstance.compact() handles cyclic references',
action: async (ctx) => {
const { objectsHelper, channelName, entryInstance } = ctx;

// Create a structure with cyclic references using REST API (realtime does not allow referencing objects by id):
// root -> map1 -> map2 -> map1 (back reference)

const { objectId: map1Id } = await objectsHelper.operationRequest(
channelName,
objectsHelper.mapCreateRestOp({ data: { foo: { string: 'bar' } } }),
);
const { objectId: map2Id } = await objectsHelper.operationRequest(
channelName,
objectsHelper.mapCreateRestOp({ data: { baz: { number: 42 } } }),
);

// Set up the cyclic references
let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'map1');
await objectsHelper.operationRequest(
channelName,
objectsHelper.mapSetRestOp({
objectId: 'root',
key: 'map1',
value: { objectId: map1Id },
}),
);
await keyUpdatedPromise;

keyUpdatedPromise = waitForMapKeyUpdate(entryInstance.get('map1'), 'map2');
await objectsHelper.operationRequest(
channelName,
objectsHelper.mapSetRestOp({
objectId: map1Id,
key: 'map2',
value: { objectId: map2Id },
}),
);
await keyUpdatedPromise;

keyUpdatedPromise = waitForMapKeyUpdate(entryInstance.get('map1').get('map2'), 'map1BackRef');
await objectsHelper.operationRequest(
channelName,
objectsHelper.mapSetRestOp({
objectId: map2Id,
key: 'map1BackRef',
value: { objectId: map1Id },
}),
);
await keyUpdatedPromise;

// Test that compact() handles cyclic references correctly
const compactEntry = entryInstance.compact();

expect(compactEntry).to.exist;
expect(compactEntry.map1).to.exist;
expect(compactEntry.map1.foo).to.equal('bar', 'Check primitive value is preserved');
expect(compactEntry.map1.map2).to.exist;
expect(compactEntry.map1.map2.baz).to.equal(42, 'Check nested primitive value is preserved');
expect(compactEntry.map1.map2.map1BackRef).to.exist;

// The back reference should point to the same object reference
expect(compactEntry.map1.map2.map1BackRef).to.equal(
compactEntry.map1,
'Check cyclic reference returns the same memoized result object',
);
},
},

{
description: 'DefaultInstance.batch() passes RootBatchContext to its batch function',
action: async (ctx) => {
Expand Down
Loading