From 967216ebd8245d8c72cd5b314ea0318614205834 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Thu, 19 Jun 2025 03:27:44 +0100 Subject: [PATCH 01/45] Change the name for the `Objects` class to `RealtimeObjects` to align with the naming convention for other realtime features in the SDK This updates ably-js implementation to match the spec [1] [1] https://github.com/ably/specification/pull/332 --- ably.d.ts | 14 +-- scripts/moduleReport.ts | 2 +- src/common/lib/client/realtimechannel.ts | 6 +- src/plugins/objects/batchcontext.ts | 4 +- .../objects/batchcontextlivecounter.ts | 4 +- src/plugins/objects/batchcontextlivemap.ts | 4 +- src/plugins/objects/index.ts | 6 +- src/plugins/objects/livecounter.ts | 12 +-- src/plugins/objects/livemap.ts | 24 +++-- src/plugins/objects/liveobject.ts | 4 +- src/plugins/objects/objectspool.ts | 4 +- .../{objects.ts => realtimeobjects.ts} | 8 +- src/plugins/objects/syncobjectsdatapool.ts | 4 +- test/common/modules/private_api_recorder.js | 16 +-- test/realtime/objects.test.js | 98 ++++++++++--------- 15 files changed, 109 insertions(+), 101 deletions(-) rename src/plugins/objects/{objects.ts => realtimeobjects.ts} (99%) diff --git a/ably.d.ts b/ably.d.ts index a324ba75b9..72af58dc27 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -1653,7 +1653,7 @@ export type ErrorCallback = (error: ErrorInfo | null) => void; export type LiveObjectUpdateCallback = (update: T) => void; /** - * The callback used for the events emitted by {@link Objects}. + * The callback used for the events emitted by {@link RealtimeObjects}. */ export type ObjectsEventCallback = () => void; @@ -1663,7 +1663,7 @@ export type ObjectsEventCallback = () => void; export type LiveObjectLifecycleEventCallback = () => void; /** - * A function passed to {@link Objects.batch} to group multiple Objects operations into a single channel message. + * A function passed to {@link RealtimeObjects.batch} to group multiple Objects operations into a single channel message. * * Must not be `async`. * @@ -2264,7 +2264,7 @@ declare namespace ObjectsEvents { } /** - * Describes the events emitted by a {@link Objects} object. + * Describes the events emitted by a {@link RealtimeObjects} object. */ export type ObjectsEvent = ObjectsEvents.SYNCED | ObjectsEvents.SYNCING; @@ -2286,7 +2286,7 @@ export type LiveObjectLifecycleEvent = LiveObjectLifecycleEvents.DELETED; /** * Enables the Objects to be read, modified and subscribed to for a channel. */ -export declare interface Objects { +export declare interface RealtimeObjects { /** * Retrieves the root {@link LiveMap} object for Objects on a channel. * @@ -2424,7 +2424,7 @@ export declare interface OnObjectsEventResponse { */ export declare interface BatchContext { /** - * Mirrors the {@link Objects.getRoot} method and returns a {@link BatchContextLiveMap} wrapper for the root object on a channel. + * Mirrors the {@link RealtimeObjects.getRoot} method and returns a {@link BatchContextLiveMap} wrapper for the root object on a channel. * * @returns A {@link BatchContextLiveMap} object. * @experimental @@ -2983,9 +2983,9 @@ export declare interface RealtimeChannel extends EventEmitter, ) { this._client = _objects.getClient(); diff --git a/src/plugins/objects/batchcontextlivecounter.ts b/src/plugins/objects/batchcontextlivecounter.ts index 7f7b6ac1d3..bc81f462ae 100644 --- a/src/plugins/objects/batchcontextlivecounter.ts +++ b/src/plugins/objects/batchcontextlivecounter.ts @@ -1,14 +1,14 @@ import type BaseClient from 'common/lib/client/baseclient'; import { BatchContext } from './batchcontext'; import { LiveCounter } from './livecounter'; -import { Objects } from './objects'; +import { RealtimeObjects } from './realtimeobjects'; export class BatchContextLiveCounter { private _client: BaseClient; constructor( private _batchContext: BatchContext, - private _objects: Objects, + private _objects: RealtimeObjects, private _counter: LiveCounter, ) { this._client = this._objects.getClient(); diff --git a/src/plugins/objects/batchcontextlivemap.ts b/src/plugins/objects/batchcontextlivemap.ts index 306b313426..4e601f4758 100644 --- a/src/plugins/objects/batchcontextlivemap.ts +++ b/src/plugins/objects/batchcontextlivemap.ts @@ -2,12 +2,12 @@ import type * as API from '../../../ably'; import { BatchContext } from './batchcontext'; import { LiveMap } from './livemap'; import { LiveObject } from './liveobject'; -import { Objects } from './objects'; +import { RealtimeObjects } from './realtimeobjects'; export class BatchContextLiveMap { constructor( private _batchContext: BatchContext, - private _objects: Objects, + private _objects: RealtimeObjects, private _map: LiveMap, ) {} diff --git a/src/plugins/objects/index.ts b/src/plugins/objects/index.ts index 6bba742bee..4739d8f110 100644 --- a/src/plugins/objects/index.ts +++ b/src/plugins/objects/index.ts @@ -1,10 +1,10 @@ import { ObjectMessage, WireObjectMessage } from './objectmessage'; -import { Objects } from './objects'; +import { RealtimeObjects } from './realtimeobjects'; -export { Objects, ObjectMessage, WireObjectMessage }; +export { ObjectMessage, RealtimeObjects, WireObjectMessage }; export default { - Objects, ObjectMessage, + RealtimeObjects, WireObjectMessage, }; diff --git a/src/plugins/objects/livecounter.ts b/src/plugins/objects/livecounter.ts index 4871cb971e..d1e7a26f52 100644 --- a/src/plugins/objects/livecounter.ts +++ b/src/plugins/objects/livecounter.ts @@ -8,7 +8,7 @@ import { ObjectOperationAction, ObjectsCounterOp, } from './objectmessage'; -import { Objects } from './objects'; +import { RealtimeObjects } from './realtimeobjects'; export interface LiveCounterData extends LiveObjectData { data: number; // RTLC3 @@ -26,7 +26,7 @@ export class LiveCounter extends LiveObject * @internal * @spec RTLC4 */ - static zeroValue(objects: Objects, objectId: string): LiveCounter { + static zeroValue(objects: RealtimeObjects, objectId: string): LiveCounter { return new LiveCounter(objects, objectId); } @@ -36,7 +36,7 @@ export class LiveCounter extends LiveObject * * @internal */ - static fromObjectState(objects: Objects, objectMessage: ObjectMessage): LiveCounter { + static fromObjectState(objects: RealtimeObjects, objectMessage: ObjectMessage): LiveCounter { const obj = new LiveCounter(objects, objectMessage.object!.objectId); obj.overrideWithObjectState(objectMessage); return obj; @@ -48,7 +48,7 @@ export class LiveCounter extends LiveObject * * @internal */ - static fromObjectOperation(objects: Objects, objectMessage: ObjectMessage): LiveCounter { + static fromObjectOperation(objects: RealtimeObjects, objectMessage: ObjectMessage): LiveCounter { const obj = new LiveCounter(objects, objectMessage.operation!.objectId); obj._mergeInitialDataFromCreateOperation(objectMessage.operation!, objectMessage); return obj; @@ -57,7 +57,7 @@ export class LiveCounter extends LiveObject /** * @internal */ - static createCounterIncMessage(objects: Objects, objectId: string, amount: number): ObjectMessage { + static createCounterIncMessage(objects: RealtimeObjects, objectId: string, amount: number): ObjectMessage { const client = objects.getClient(); if (typeof amount !== 'number' || !Number.isFinite(amount)) { @@ -82,7 +82,7 @@ export class LiveCounter extends LiveObject /** * @internal */ - static async createCounterCreateMessage(objects: Objects, count?: number): Promise { + static async createCounterCreateMessage(objects: RealtimeObjects, count?: number): Promise { const client = objects.getClient(); if (count !== undefined && (typeof count !== 'number' || !Number.isFinite(count))) { diff --git a/src/plugins/objects/livemap.ts b/src/plugins/objects/livemap.ts index 1be3c6a9ba..d9e821c928 100644 --- a/src/plugins/objects/livemap.ts +++ b/src/plugins/objects/livemap.ts @@ -15,7 +15,7 @@ import { ObjectsMapSemantics, PrimitiveObjectValue, } from './objectmessage'; -import { Objects } from './objects'; +import { RealtimeObjects } from './realtimeobjects'; export interface ObjectIdObjectData { /** A reference to another object, used to support composable object structures. */ @@ -47,7 +47,7 @@ export interface LiveMapUpdate extends LiveObjectUpda /** @spec RTLM1, RTLM2 */ export class LiveMap extends LiveObject> { constructor( - objects: Objects, + objects: RealtimeObjects, private _semantics: ObjectsMapSemantics, objectId: string, ) { @@ -60,7 +60,7 @@ export class LiveMap extends LiveObject(objects: Objects, objectId: string): LiveMap { + static zeroValue(objects: RealtimeObjects, objectId: string): LiveMap { return new LiveMap(objects, ObjectsMapSemantics.LWW, objectId); } @@ -70,7 +70,10 @@ export class LiveMap extends LiveObject(objects: Objects, objectMessage: ObjectMessage): LiveMap { + static fromObjectState( + objects: RealtimeObjects, + objectMessage: ObjectMessage, + ): LiveMap { const obj = new LiveMap(objects, objectMessage.object!.map!.semantics!, objectMessage.object!.objectId); obj.overrideWithObjectState(objectMessage); return obj; @@ -82,7 +85,10 @@ export class LiveMap extends LiveObject(objects: Objects, objectMessage: ObjectMessage): LiveMap { + static fromObjectOperation( + objects: RealtimeObjects, + objectMessage: ObjectMessage, + ): LiveMap { const obj = new LiveMap(objects, objectMessage.operation!.map?.semantics!, objectMessage.operation!.objectId); obj._mergeInitialDataFromCreateOperation(objectMessage.operation!, objectMessage); return obj; @@ -92,7 +98,7 @@ export class LiveMap extends LiveObject( - objects: Objects, + objects: RealtimeObjects, objectId: string, key: TKey, value: API.LiveMapType[TKey], @@ -132,7 +138,7 @@ export class LiveMap extends LiveObject( - objects: Objects, + objects: RealtimeObjects, objectId: string, key: TKey, ): ObjectMessage { @@ -161,7 +167,7 @@ export class LiveMap extends LiveObject( - objects: Objects, + objects: RealtimeObjects, key: TKey, value: API.LiveMapType[TKey], ): void { @@ -185,7 +191,7 @@ export class LiveMap extends LiveObject { + static async createMapCreateMessage(objects: RealtimeObjects, entries?: API.LiveMapType): Promise { const client = objects.getClient(); if (entries !== undefined && (entries === null || typeof entries !== 'object')) { diff --git a/src/plugins/objects/liveobject.ts b/src/plugins/objects/liveobject.ts index 2205fa29ae..d8d8701d85 100644 --- a/src/plugins/objects/liveobject.ts +++ b/src/plugins/objects/liveobject.ts @@ -1,7 +1,7 @@ import type BaseClient from 'common/lib/client/baseclient'; import type EventEmitter from 'common/lib/util/eventemitter'; import { ObjectData, ObjectMessage, ObjectOperation } from './objectmessage'; -import { Objects } from './objects'; +import { RealtimeObjects } from './realtimeobjects'; export enum LiveObjectSubscriptionEvent { updated = 'updated', @@ -56,7 +56,7 @@ export abstract class LiveObject< private _tombstonedAt: number | undefined; protected constructor( - protected _objects: Objects, + protected _objects: RealtimeObjects, objectId: string, ) { this._client = this._objects.getClient(); diff --git a/src/plugins/objects/objectspool.ts b/src/plugins/objects/objectspool.ts index 7401924813..e76e873e0e 100644 --- a/src/plugins/objects/objectspool.ts +++ b/src/plugins/objects/objectspool.ts @@ -4,7 +4,7 @@ import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; import { LiveObject } from './liveobject'; import { ObjectId } from './objectid'; -import { Objects } from './objects'; +import { RealtimeObjects } from './realtimeobjects'; export const ROOT_OBJECT_ID = 'root'; @@ -17,7 +17,7 @@ export class ObjectsPool { private _pool: Map; // RTO3a private _gcInterval: ReturnType; - constructor(private _objects: Objects) { + constructor(private _objects: RealtimeObjects) { this._client = this._objects.getClient(); this._pool = this._createInitialPool(); this._gcInterval = setInterval(() => { diff --git a/src/plugins/objects/objects.ts b/src/plugins/objects/realtimeobjects.ts similarity index 99% rename from src/plugins/objects/objects.ts rename to src/plugins/objects/realtimeobjects.ts index 907509134a..c74cc0bcc3 100644 --- a/src/plugins/objects/objects.ts +++ b/src/plugins/objects/realtimeobjects.ts @@ -36,7 +36,7 @@ export interface OnObjectsEventResponse { export type BatchCallback = (batchContext: BatchContext) => void; -export class Objects { +export class RealtimeObjects { gcGracePeriod: number; private _client: BaseClient; @@ -263,7 +263,7 @@ export class Objects { this._client.Logger.logAction( this._client.logger, this._client.Logger.LOG_MINOR, - 'Objects.onAttached()', + 'RealtimeObjects.onAttached()', `channel=${this._channel.name}, hasObjects=${hasObjects}`, ); @@ -432,7 +432,7 @@ export class Objects { this._client.Logger.logAction( this._client.logger, this._client.Logger.LOG_MAJOR, - 'Objects._applyObjectMessages()', + 'RealtimeObjects._applyObjectMessages()', `object operation message is received without 'operation' field, skipping message; message id: ${objectMessage.id}, channel: ${this._channel.name}`, ); continue; @@ -461,7 +461,7 @@ export class Objects { this._client.Logger.logAction( this._client.logger, this._client.Logger.LOG_MAJOR, - 'Objects._applyObjectMessages()', + 'RealtimeObjects._applyObjectMessages()', `received unsupported action in object operation message: ${objectOperation.action}, skipping message; message id: ${objectMessage.id}, channel: ${this._channel.name}`, ); } diff --git a/src/plugins/objects/syncobjectsdatapool.ts b/src/plugins/objects/syncobjectsdatapool.ts index f893b18240..66dd5d36e3 100644 --- a/src/plugins/objects/syncobjectsdatapool.ts +++ b/src/plugins/objects/syncobjectsdatapool.ts @@ -1,7 +1,7 @@ import type BaseClient from 'common/lib/client/baseclient'; import type RealtimeChannel from 'common/lib/client/realtimechannel'; import { ObjectMessage } from './objectmessage'; -import { Objects } from './objects'; +import { RealtimeObjects } from './realtimeobjects'; export interface LiveObjectDataEntry { objectMessage: ObjectMessage; @@ -27,7 +27,7 @@ export class SyncObjectsDataPool { private _channel: RealtimeChannel; private _pool: Map; - constructor(private _objects: Objects) { + constructor(private _objects: RealtimeObjects) { this._client = this._objects.getClient(); this._channel = this._objects.getChannel(); this._pool = new Map(); diff --git a/test/common/modules/private_api_recorder.js b/test/common/modules/private_api_recorder.js index 04e597257f..15434cda14 100644 --- a/test/common/modules/private_api_recorder.js +++ b/test/common/modules/private_api_recorder.js @@ -17,8 +17,6 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths) 'call.LiveObject.getObjectId', 'call.LiveObject.isTombstoned', 'call.LiveObject.tombstonedAt', - 'call.Objects._objectsPool._onGCInterval', - 'call.Objects._objectsPool.get', 'call.Message.decode', 'call.Message.encode', 'call.ObjectMessage.encode', @@ -28,6 +26,8 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths) 'call.Platform.nextTick', 'call.PresenceMessage.fromValues', 'call.ProtocolMessage.setFlag', + 'call.RealtimeObjects._objectsPool._onGCInterval', + 'call.RealtimeObjects._objectsPool.get', 'call.Utils.copy', 'call.Utils.dataSizeBytes', 'call.Utils.getRetryTime', @@ -80,11 +80,11 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths) 'read.Defaults.version', 'read.LiveMap._dataRef.data', 'read.EventEmitter.events', - 'read.Objects._DEFAULTS.gcGracePeriod', - 'read.Objects.gcGracePeriod', 'read.Platform.Config.push', 'read.ProtocolMessage.channelSerial', 'read.Realtime._transports', + 'read.RealtimeObjects._DEFAULTS.gcGracePeriod', + 'read.RealtimeObjects.gcGracePeriod', 'read.auth.authOptions.authUrl', 'read.auth.key', 'read.auth.method', @@ -124,8 +124,8 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths) 'read.transport.params.mode', 'read.transport.recvRequest.recvUri', 'read.transport.uri', - 'replace.Objects._objectsPool._onGCInterval', - 'replace.Objects.publish', + 'replace.RealtimeObjects._objectsPool._onGCInterval', + 'replace.RealtimeObjects.publish', 'replace.channel.attachImpl', 'replace.channel.processMessage', 'replace.channel.sendMessage', @@ -143,9 +143,9 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths) 'write.Defaults.ENDPOINT', 'write.Defaults.ENVIRONMENT', 'write.Defaults.wsConnectivityCheckUrl', - 'write.Objects._DEFAULTS.gcInterval', - 'write.Objects.gcGracePeriod', 'write.Platform.Config.push', // This implies using a mock implementation of the internal IPlatformPushConfig interface. Our mock (in push_channel_transport.js) then interacts with internal objects and private APIs of public objects to implement this interface; I haven’t added annotations for that private API usage, since there wasn’t an easy way to pass test context information into the mock. I think that for now we can just say that if we wanted to get rid of this private API usage, then we’d need to remove this mock entirely. + 'write.RealtimeObjects._DEFAULTS.gcInterval', + 'write.RealtimeObjects.gcGracePeriod', 'write.auth.authOptions.requestHeaders', 'write.auth.key', 'write.auth.tokenDetails.token', diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js index ebcb821d29..4c45fc15cd 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/objects.test.js @@ -14,7 +14,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const createPM = Ably.makeProtocolMessageFromDeserialized({ ObjectsPlugin }); const objectsFixturesChannel = 'objects_fixtures'; const nextTick = Ably.Realtime.Platform.Config.nextTick; - const gcIntervalOriginal = ObjectsPlugin.Objects._DEFAULTS.gcInterval; + const gcIntervalOriginal = ObjectsPlugin.RealtimeObjects._DEFAULTS.gcInterval; function RealtimeWithObjects(helper, options) { return helper.AblyRealtime({ ...options, plugins: { Objects: ObjectsPlugin } }); @@ -272,11 +272,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function describe('Realtime with Objects plugin', () => { /** @nospec */ - it("returns Objects class instance when accessing channel's `objects` property", async function () { + it("returns RealtimeObjects class instance when accessing channel's `objects` property", async function () { const helper = this.test.helper; const client = RealtimeWithObjects(helper, { autoConnect: false }); const channel = client.channels.get('channel'); - expectInstanceOf(channel.objects, 'Objects'); + expectInstanceOf(channel.objects, 'RealtimeObjects'); }); /** @nospec */ @@ -968,7 +968,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ], }); - helper.recordPrivateApi('call.Objects._objectsPool.get'); + helper.recordPrivateApi('call.RealtimeObjects._objectsPool.get'); const obj = objects._objectsPool.get(counterId); expect(obj, 'Check object added to the pool OBJECT_SYNC sequence with "tombstone=true"').to.exist; helper.recordPrivateApi('call.LiveObject.tombstonedAt'); @@ -1011,7 +1011,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); const tsAfterMsg = Date.now(); - helper.recordPrivateApi('call.Objects._objectsPool.get'); + helper.recordPrivateApi('call.RealtimeObjects._objectsPool.get'); const obj = objects._objectsPool.get(counterId); expect(obj, 'Check object added to the pool OBJECT_SYNC sequence with "tombstone=true"').to.exist; helper.recordPrivateApi('call.LiveObject.tombstonedAt'); @@ -2154,7 +2154,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function state: [objectsHelper.objectDeleteOp({ objectId })], }); - helper.recordPrivateApi('call.Objects._objectsPool.get'); + helper.recordPrivateApi('call.RealtimeObjects._objectsPool.get'); const obj = objects._objectsPool.get(objectId); helper.recordPrivateApi('call.LiveObject.isTombstoned'); expect(obj.isTombstoned()).to.equal(true, `Check object is tombstoned after OBJECT_DELETE`); @@ -2192,7 +2192,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); const tsAfterMsg = Date.now(); - helper.recordPrivateApi('call.Objects._objectsPool.get'); + helper.recordPrivateApi('call.RealtimeObjects._objectsPool.get'); const obj = objects._objectsPool.get(objectId); helper.recordPrivateApi('call.LiveObject.isTombstoned'); expect(obj.isTombstoned()).to.equal(true, `Check object is tombstoned after OBJECT_DELETE`); @@ -3076,7 +3076,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { allTransportsAndProtocols: true, - description: 'Objects.createCounter sends COUNTER_CREATE operation', + description: 'RealtimeObjects.createCounter sends COUNTER_CREATE operation', action: async (ctx) => { const { objects } = ctx; @@ -3098,7 +3098,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { allTransportsAndProtocols: true, - description: 'LiveCounter created with Objects.createCounter can be assigned to the object tree', + description: 'LiveCounter created with RealtimeObjects.createCounter can be assigned to the object tree', action: async (ctx) => { const { root, objects } = ctx; @@ -3126,12 +3126,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: - 'Objects.createCounter can return LiveCounter with initial value without applying CREATE operation', + 'RealtimeObjects.createCounter can return LiveCounter with initial value without applying CREATE operation', action: async (ctx) => { const { objects, helper } = ctx; // prevent publishing of ops to realtime so we guarantee that the initial value doesn't come from a CREATE op - helper.recordPrivateApi('replace.Objects.publish'); + helper.recordPrivateApi('replace.RealtimeObjects.publish'); objects.publish = () => {}; const counter = await objects.createCounter(1); @@ -3141,13 +3141,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { allTransportsAndProtocols: true, - description: 'Objects.createCounter can return LiveCounter with initial value from applied CREATE operation', + description: + 'RealtimeObjects.createCounter can return LiveCounter with initial value from applied CREATE operation', action: async (ctx) => { const { objects, objectsHelper, helper, channel } = ctx; // instead of sending CREATE op to the realtime, echo it immediately to the client // with forged initial value so we can check that counter gets initialized with a value from a CREATE op - helper.recordPrivateApi('replace.Objects.publish'); + helper.recordPrivateApi('replace.RealtimeObjects.publish'); objects.publish = async (objectMessages) => { const counterId = objectMessages[0].operation.objectId; // this should result execute regular operation application procedure and create an object in the pool with forged initial value @@ -3171,12 +3172,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: - 'initial value is not double counted for LiveCounter from Objects.createCounter when CREATE op is received', + 'initial value is not double counted for LiveCounter from RealtimeObjects.createCounter when CREATE op is received', action: async (ctx) => { const { objects, objectsHelper, helper, channel } = ctx; // prevent publishing of ops to realtime so we can guarantee order of operations - helper.recordPrivateApi('replace.Objects.publish'); + helper.recordPrivateApi('replace.RealtimeObjects.publish'); objects.publish = () => {}; // create counter locally, should have an initial value set @@ -3200,7 +3201,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, { - description: 'Objects.createCounter throws on invalid input', + description: 'RealtimeObjects.createCounter throws on invalid input', action: async (ctx) => { const { root, objects } = ctx; @@ -3238,7 +3239,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { allTransportsAndProtocols: true, - description: 'Objects.createMap sends MAP_CREATE operation with primitive values', + description: 'RealtimeObjects.createMap sends MAP_CREATE operation with primitive values', action: async (ctx) => { const { objects, helper } = ctx; @@ -3292,7 +3293,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { allTransportsAndProtocols: true, - description: 'Objects.createMap sends MAP_CREATE operation with reference to another LiveObject', + description: 'RealtimeObjects.createMap sends MAP_CREATE operation with reference to another LiveObject', action: async (ctx) => { const { root, objectsHelper, channelName, objects } = ctx; @@ -3333,7 +3334,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { allTransportsAndProtocols: true, - description: 'LiveMap created with Objects.createMap can be assigned to the object tree', + description: 'LiveMap created with RealtimeObjects.createMap can be assigned to the object tree', action: async (ctx) => { const { root, objects } = ctx; @@ -3362,12 +3363,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, { - description: 'Objects.createMap can return LiveMap with initial value without applying CREATE operation', + description: + 'RealtimeObjects.createMap can return LiveMap with initial value without applying CREATE operation', action: async (ctx) => { const { objects, helper } = ctx; // prevent publishing of ops to realtime so we guarantee that the initial value doesn't come from a CREATE op - helper.recordPrivateApi('replace.Objects.publish'); + helper.recordPrivateApi('replace.RealtimeObjects.publish'); objects.publish = () => {}; const map = await objects.createMap({ foo: 'bar' }); @@ -3377,13 +3379,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { allTransportsAndProtocols: true, - description: 'Objects.createMap can return LiveMap with initial value from applied CREATE operation', + description: 'RealtimeObjects.createMap can return LiveMap with initial value from applied CREATE operation', action: async (ctx) => { const { objects, objectsHelper, helper, channel } = ctx; // instead of sending CREATE op to the realtime, echo it immediately to the client // with forged initial value so we can check that map gets initialized with a value from a CREATE op - helper.recordPrivateApi('replace.Objects.publish'); + helper.recordPrivateApi('replace.RealtimeObjects.publish'); objects.publish = async (objectMessages) => { const mapId = objectMessages[0].operation.objectId; // this should result execute regular operation application procedure and create an object in the pool with forged initial value @@ -3413,12 +3415,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: - 'initial value is not double counted for LiveMap from Objects.createMap when CREATE op is received', + 'initial value is not double counted for LiveMap from RealtimeObjects.createMap when CREATE op is received', action: async (ctx) => { const { objects, objectsHelper, helper, channel } = ctx; // prevent publishing of ops to realtime so we can guarantee order of operations - helper.recordPrivateApi('replace.Objects.publish'); + helper.recordPrivateApi('replace.RealtimeObjects.publish'); objects.publish = () => {}; // create map locally, should have an initial value set @@ -3452,7 +3454,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, { - description: 'Objects.createMap throws on invalid input', + description: 'RealtimeObjects.createMap throws on invalid input', action: async (ctx) => { const { root, objects } = ctx; @@ -4666,12 +4668,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const connectionDetails = connectionManager.connectionDetails; // gcGracePeriod should be set after the initial connection - helper.recordPrivateApi('read.Objects.gcGracePeriod'); + helper.recordPrivateApi('read.RealtimeObjects.gcGracePeriod'); expect( objects.gcGracePeriod, 'Check gcGracePeriod is set after initial connection from connectionDetails.objectsGCGracePeriod', ).to.exist; - helper.recordPrivateApi('read.Objects.gcGracePeriod'); + helper.recordPrivateApi('read.RealtimeObjects.gcGracePeriod'); expect(objects.gcGracePeriod).to.equal( connectionDetails.objectsGCGracePeriod, 'Check gcGracePeriod is set to equal connectionDetails.objectsGCGracePeriod', @@ -4697,7 +4699,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // wait for next tick to ensure the connectionDetails event was processed by Objects plugin await new Promise((res) => nextTick(res)); - helper.recordPrivateApi('read.Objects.gcGracePeriod'); + helper.recordPrivateApi('read.RealtimeObjects.gcGracePeriod'); expect(objects.gcGracePeriod).to.equal(999, 'Check gcGracePeriod is updated on new CONNECTED event'); }, client); }); @@ -4714,10 +4716,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const connectionManager = client.connection.connectionManager; const connectionDetails = connectionManager.connectionDetails; - helper.recordPrivateApi('read.Objects._DEFAULTS.gcGracePeriod'); - helper.recordPrivateApi('write.Objects.gcGracePeriod'); + helper.recordPrivateApi('read.RealtimeObjects._DEFAULTS.gcGracePeriod'); + helper.recordPrivateApi('write.RealtimeObjects.gcGracePeriod'); // set gcGracePeriod to a value different from the default - objects.gcGracePeriod = ObjectsPlugin.Objects._DEFAULTS.gcGracePeriod + 1; + objects.gcGracePeriod = ObjectsPlugin.RealtimeObjects._DEFAULTS.gcGracePeriod + 1; const connectionDetailsPromise = connectionManager.once('connectiondetails'); @@ -4737,10 +4739,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // wait for next tick to ensure the connectionDetails event was processed by Objects plugin await new Promise((res) => nextTick(res)); - helper.recordPrivateApi('read.Objects._DEFAULTS.gcGracePeriod'); - helper.recordPrivateApi('read.Objects.gcGracePeriod'); + helper.recordPrivateApi('read.RealtimeObjects._DEFAULTS.gcGracePeriod'); + helper.recordPrivateApi('read.RealtimeObjects.gcGracePeriod'); expect(objects.gcGracePeriod).to.equal( - ObjectsPlugin.Objects._DEFAULTS.gcGracePeriod, + ObjectsPlugin.RealtimeObjects._DEFAULTS.gcGracePeriod, 'Check gcGracePeriod is set to a default value if connectionDetails.objectsGCGracePeriod is missing', ); }, client); @@ -4762,7 +4764,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ); await counterCreatedPromise; - helper.recordPrivateApi('call.Objects._objectsPool.get'); + helper.recordPrivateApi('call.RealtimeObjects._objectsPool.get'); expect(objects._objectsPool.get(objectId), 'Check object exists in the pool after creation').to.exist; // inject OBJECT_DELETE for the object. this should tombstone the object and make it inaccessible to the end user, but still keep it in memory in the local pool @@ -4773,12 +4775,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function state: [objectsHelper.objectDeleteOp({ objectId })], }); - helper.recordPrivateApi('call.Objects._objectsPool.get'); + helper.recordPrivateApi('call.RealtimeObjects._objectsPool.get'); expect( objects._objectsPool.get(objectId), 'Check object exists in the pool immediately after OBJECT_DELETE', ).to.exist; - helper.recordPrivateApi('call.Objects._objectsPool.get'); + helper.recordPrivateApi('call.RealtimeObjects._objectsPool.get'); helper.recordPrivateApi('call.LiveObject.isTombstoned'); expect(objects._objectsPool.get(objectId).isTombstoned()).to.equal( true, @@ -4789,7 +4791,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await waitForGCCycles(2); // object should be removed from the local pool entirely now, as the GC grace period has passed - helper.recordPrivateApi('call.Objects._objectsPool.get'); + helper.recordPrivateApi('call.RealtimeObjects._objectsPool.get'); expect( objects._objectsPool.get(objectId), 'Check object exists does not exist in the pool after the GC grace period expiration', @@ -4850,8 +4852,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function /** @nospec */ forScenarios(this, tombstonesGCScenarios, async function (helper, scenario, clientOptions, channelName) { try { - helper.recordPrivateApi('write.Objects._DEFAULTS.gcInterval'); - ObjectsPlugin.Objects._DEFAULTS.gcInterval = 500; + helper.recordPrivateApi('write.RealtimeObjects._DEFAULTS.gcInterval'); + ObjectsPlugin.RealtimeObjects._DEFAULTS.gcInterval = 500; const objectsHelper = new ObjectsHelper(helper); const client = RealtimeWithObjects(helper, clientOptions); @@ -4863,9 +4865,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await channel.attach(); const root = await objects.getRoot(); - helper.recordPrivateApi('read.Objects.gcGracePeriod'); + helper.recordPrivateApi('read.RealtimeObjects.gcGracePeriod'); const gcGracePeriodOriginal = objects.gcGracePeriod; - helper.recordPrivateApi('write.Objects.gcGracePeriod'); + helper.recordPrivateApi('write.RealtimeObjects.gcGracePeriod'); objects.gcGracePeriod = 250; // helper function to spy on the GC interval callback and wait for a specific number of GC cycles. @@ -4874,9 +4876,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const onGCIntervalOriginal = objects._objectsPool._onGCInterval; let gcCalledTimes = 0; return new Promise((resolve) => { - helper.recordPrivateApi('replace.Objects._objectsPool._onGCInterval'); + helper.recordPrivateApi('replace.RealtimeObjects._objectsPool._onGCInterval'); objects._objectsPool._onGCInterval = function () { - helper.recordPrivateApi('call.Objects._objectsPool._onGCInterval'); + helper.recordPrivateApi('call.RealtimeObjects._objectsPool._onGCInterval'); onGCIntervalOriginal.call(this); gcCalledTimes++; @@ -4899,12 +4901,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function waitForGCCycles, }); - helper.recordPrivateApi('write.Objects.gcGracePeriod'); + helper.recordPrivateApi('write.RealtimeObjects.gcGracePeriod'); objects.gcGracePeriod = gcGracePeriodOriginal; }, client); } finally { - helper.recordPrivateApi('write.Objects._DEFAULTS.gcInterval'); - ObjectsPlugin.Objects._DEFAULTS.gcInterval = gcIntervalOriginal; + helper.recordPrivateApi('write.RealtimeObjects._DEFAULTS.gcInterval'); + ObjectsPlugin.RealtimeObjects._DEFAULTS.gcInterval = gcIntervalOriginal; } }); From 0b97dc6dd6a588da4abf8fef99784a256fb7481d Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Tue, 16 Sep 2025 07:42:38 +0100 Subject: [PATCH 02/45] Change LiveObjects entry point to `channel.object.get()` and rename `RealtimeObjects` to `RealtimeObject` Resolves https://ably.atlassian.net/browse/PUB-2059 --- ably.d.ts | 20 +- scripts/moduleReport.ts | 2 +- src/common/lib/client/realtimechannel.ts | 26 +- src/plugins/objects/batchcontext.ts | 20 +- .../objects/batchcontextlivecounter.ts | 14 +- src/plugins/objects/batchcontextlivemap.ts | 22 +- src/plugins/objects/index.ts | 6 +- src/plugins/objects/livecounter.ts | 32 +- src/plugins/objects/livemap.ts | 71 +- src/plugins/objects/liveobject.ts | 8 +- src/plugins/objects/objectspool.ts | 14 +- .../{realtimeobjects.ts => realtimeobject.ts} | 14 +- src/plugins/objects/syncobjectsdatapool.ts | 8 +- test/common/modules/private_api_recorder.js | 16 +- .../browser/template/src/index-objects.ts | 7 +- test/realtime/objects.test.js | 605 ++++++++++-------- 16 files changed, 464 insertions(+), 421 deletions(-) rename src/plugins/objects/{realtimeobjects.ts => realtimeobject.ts} (97%) diff --git a/ably.d.ts b/ably.d.ts index 72af58dc27..46ce7c20d7 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -626,7 +626,7 @@ export interface CorePlugins { Push?: unknown; /** - * A plugin which allows the client to use LiveObjects functionality at {@link RealtimeChannel.objects}. + * A plugin which allows the client to use LiveObjects functionality at {@link RealtimeChannel.object}. */ Objects?: unknown; } @@ -1653,7 +1653,7 @@ export type ErrorCallback = (error: ErrorInfo | null) => void; export type LiveObjectUpdateCallback = (update: T) => void; /** - * The callback used for the events emitted by {@link RealtimeObjects}. + * The callback used for the events emitted by {@link RealtimeObject}. */ export type ObjectsEventCallback = () => void; @@ -1663,7 +1663,7 @@ export type ObjectsEventCallback = () => void; export type LiveObjectLifecycleEventCallback = () => void; /** - * A function passed to {@link RealtimeObjects.batch} to group multiple Objects operations into a single channel message. + * A function passed to {@link RealtimeObject.batch} to group multiple Objects operations into a single channel message. * * Must not be `async`. * @@ -2264,7 +2264,7 @@ declare namespace ObjectsEvents { } /** - * Describes the events emitted by a {@link RealtimeObjects} object. + * Describes the events emitted by a {@link RealtimeObject} object. */ export type ObjectsEvent = ObjectsEvents.SYNCED | ObjectsEvents.SYNCING; @@ -2286,7 +2286,7 @@ export type LiveObjectLifecycleEvent = LiveObjectLifecycleEvents.DELETED; /** * Enables the Objects to be read, modified and subscribed to for a channel. */ -export declare interface RealtimeObjects { +export declare interface RealtimeObject { /** * Retrieves the root {@link LiveMap} object for Objects on a channel. * @@ -2313,7 +2313,7 @@ export declare interface RealtimeObjects { * @returns A promise which, upon success, will be fulfilled with a {@link LiveMap} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. * @experimental */ - getRoot(): Promise>; + get(): Promise>; /** * Creates a new {@link LiveMap} object instance with the provided entries. @@ -2424,12 +2424,12 @@ export declare interface OnObjectsEventResponse { */ export declare interface BatchContext { /** - * Mirrors the {@link RealtimeObjects.getRoot} method and returns a {@link BatchContextLiveMap} wrapper for the root object on a channel. + * Mirrors the {@link RealtimeObject.get} method and returns a {@link BatchContextLiveMap} wrapper for the root object on a channel. * * @returns A {@link BatchContextLiveMap} object. * @experimental */ - getRoot(): BatchContextLiveMap; + get(): BatchContextLiveMap; } /** @@ -2983,9 +2983,9 @@ export declare interface RealtimeChannel extends EventEmitter om.decode(this.client, format)); if (message.action === actions.OBJECT) { - this._objects.handleObjectMessages(objectMessages); + this._object.handleObjectMessages(objectMessages); } else { - this._objects.handleObjectSyncMessages(objectMessages, message.channelSerial); + this._object.handleObjectSyncMessages(objectMessages, message.channelSerial); } break; @@ -798,8 +798,8 @@ class RealtimeChannel extends EventEmitter { if (this._presence) { this._presence.actOnChannelState(state, hasPresence, reason); } - if (this._objects) { - this._objects.actOnChannelState(state, hasObjects); + if (this._object) { + this._object.actOnChannelState(state, hasObjects); } if (state === 'suspended' && this.connectionManager.state.sendEvents) { this.startRetryTimer(); diff --git a/src/plugins/objects/batchcontext.ts b/src/plugins/objects/batchcontext.ts index faf713b2a7..f416daf9f7 100644 --- a/src/plugins/objects/batchcontext.ts +++ b/src/plugins/objects/batchcontext.ts @@ -6,7 +6,7 @@ import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; import { ObjectMessage } from './objectmessage'; import { ROOT_OBJECT_ID } from './objectspool'; -import { RealtimeObjects } from './realtimeobjects'; +import { RealtimeObject } from './realtimeobject'; export class BatchContext { private _client: BaseClient; @@ -16,15 +16,15 @@ export class BatchContext { private _isClosed = false; constructor( - private _objects: RealtimeObjects, + private _realtimeObject: RealtimeObject, private _root: LiveMap, ) { - this._client = _objects.getClient(); - this._wrappedObjects.set(this._root.getObjectId(), new BatchContextLiveMap(this, this._objects, this._root)); + this._client = _realtimeObject.getClient(); + this._wrappedObjects.set(this._root.getObjectId(), new BatchContextLiveMap(this, this._realtimeObject, this._root)); } - getRoot(): BatchContextLiveMap { - this._objects.throwIfInvalidAccessApiConfiguration(); + get(): BatchContextLiveMap { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); this.throwIfClosed(); return this.getWrappedObject(ROOT_OBJECT_ID) as BatchContextLiveMap; } @@ -37,16 +37,16 @@ export class BatchContext { return this._wrappedObjects.get(objectId); } - const originObject = this._objects.getPool().get(objectId); + const originObject = this._realtimeObject.getPool().get(objectId); if (!originObject) { return undefined; } let wrappedObject: BatchContextLiveCounter | BatchContextLiveMap; if (originObject instanceof LiveMap) { - wrappedObject = new BatchContextLiveMap(this, this._objects, originObject); + wrappedObject = new BatchContextLiveMap(this, this._realtimeObject, originObject); } else if (originObject instanceof LiveCounter) { - wrappedObject = new BatchContextLiveCounter(this, this._objects, originObject); + wrappedObject = new BatchContextLiveCounter(this, this._realtimeObject, originObject); } else { throw new this._client.ErrorInfo( `Unknown LiveObject instance type: objectId=${originObject.getObjectId()}`, @@ -97,7 +97,7 @@ export class BatchContext { this.close(); if (this._queuedMessages.length > 0) { - await this._objects.publish(this._queuedMessages); + await this._realtimeObject.publish(this._queuedMessages); } } finally { this._wrappedObjects.clear(); diff --git a/src/plugins/objects/batchcontextlivecounter.ts b/src/plugins/objects/batchcontextlivecounter.ts index bc81f462ae..fc1a9f9ebf 100644 --- a/src/plugins/objects/batchcontextlivecounter.ts +++ b/src/plugins/objects/batchcontextlivecounter.ts @@ -1,34 +1,34 @@ import type BaseClient from 'common/lib/client/baseclient'; import { BatchContext } from './batchcontext'; import { LiveCounter } from './livecounter'; -import { RealtimeObjects } from './realtimeobjects'; +import { RealtimeObject } from './realtimeobject'; export class BatchContextLiveCounter { private _client: BaseClient; constructor( private _batchContext: BatchContext, - private _objects: RealtimeObjects, + private _realtimeObject: RealtimeObject, private _counter: LiveCounter, ) { - this._client = this._objects.getClient(); + this._client = this._realtimeObject.getClient(); } value(): number { - this._objects.throwIfInvalidAccessApiConfiguration(); + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); this._batchContext.throwIfClosed(); return this._counter.value(); } increment(amount: number): void { - this._objects.throwIfInvalidWriteApiConfiguration(); + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); this._batchContext.throwIfClosed(); - const msg = LiveCounter.createCounterIncMessage(this._objects, this._counter.getObjectId(), amount); + const msg = LiveCounter.createCounterIncMessage(this._realtimeObject, this._counter.getObjectId(), amount); this._batchContext.queueMessage(msg); } decrement(amount: number): void { - this._objects.throwIfInvalidWriteApiConfiguration(); + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); this._batchContext.throwIfClosed(); // do an explicit type safety check here before negating the amount value, // so we don't unintentionally change the type sent by a user diff --git a/src/plugins/objects/batchcontextlivemap.ts b/src/plugins/objects/batchcontextlivemap.ts index 4e601f4758..6edacedde2 100644 --- a/src/plugins/objects/batchcontextlivemap.ts +++ b/src/plugins/objects/batchcontextlivemap.ts @@ -2,17 +2,17 @@ import type * as API from '../../../ably'; import { BatchContext } from './batchcontext'; import { LiveMap } from './livemap'; import { LiveObject } from './liveobject'; -import { RealtimeObjects } from './realtimeobjects'; +import { RealtimeObject } from './realtimeobject'; export class BatchContextLiveMap { constructor( private _batchContext: BatchContext, - private _objects: RealtimeObjects, + private _realtimeObject: RealtimeObject, private _map: LiveMap, ) {} get(key: TKey): T[TKey] | undefined { - this._objects.throwIfInvalidAccessApiConfiguration(); + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); this._batchContext.throwIfClosed(); const value = this._map.get(key); if (value instanceof LiveObject) { @@ -23,40 +23,40 @@ export class BatchContextLiveMap { } size(): number { - this._objects.throwIfInvalidAccessApiConfiguration(); + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); this._batchContext.throwIfClosed(); return this._map.size(); } *entries(): IterableIterator<[TKey, T[TKey]]> { - this._objects.throwIfInvalidAccessApiConfiguration(); + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); this._batchContext.throwIfClosed(); yield* this._map.entries(); } *keys(): IterableIterator { - this._objects.throwIfInvalidAccessApiConfiguration(); + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); this._batchContext.throwIfClosed(); yield* this._map.keys(); } *values(): IterableIterator { - this._objects.throwIfInvalidAccessApiConfiguration(); + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); this._batchContext.throwIfClosed(); yield* this._map.values(); } set(key: TKey, value: T[TKey]): void { - this._objects.throwIfInvalidWriteApiConfiguration(); + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); this._batchContext.throwIfClosed(); - const msg = LiveMap.createMapSetMessage(this._objects, this._map.getObjectId(), key, value); + const msg = LiveMap.createMapSetMessage(this._realtimeObject, this._map.getObjectId(), key, value); this._batchContext.queueMessage(msg); } remove(key: TKey): void { - this._objects.throwIfInvalidWriteApiConfiguration(); + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); this._batchContext.throwIfClosed(); - const msg = LiveMap.createMapRemoveMessage(this._objects, this._map.getObjectId(), key); + const msg = LiveMap.createMapRemoveMessage(this._realtimeObject, this._map.getObjectId(), key); this._batchContext.queueMessage(msg); } } diff --git a/src/plugins/objects/index.ts b/src/plugins/objects/index.ts index 4739d8f110..bac74ad34d 100644 --- a/src/plugins/objects/index.ts +++ b/src/plugins/objects/index.ts @@ -1,10 +1,10 @@ import { ObjectMessage, WireObjectMessage } from './objectmessage'; -import { RealtimeObjects } from './realtimeobjects'; +import { RealtimeObject } from './realtimeobject'; -export { ObjectMessage, RealtimeObjects, WireObjectMessage }; +export { ObjectMessage, RealtimeObject, WireObjectMessage }; export default { ObjectMessage, - RealtimeObjects, + RealtimeObject, WireObjectMessage, }; diff --git a/src/plugins/objects/livecounter.ts b/src/plugins/objects/livecounter.ts index d1e7a26f52..9dc0d821ca 100644 --- a/src/plugins/objects/livecounter.ts +++ b/src/plugins/objects/livecounter.ts @@ -8,7 +8,7 @@ import { ObjectOperationAction, ObjectsCounterOp, } from './objectmessage'; -import { RealtimeObjects } from './realtimeobjects'; +import { RealtimeObject } from './realtimeobject'; export interface LiveCounterData extends LiveObjectData { data: number; // RTLC3 @@ -26,8 +26,8 @@ export class LiveCounter extends LiveObject * @internal * @spec RTLC4 */ - static zeroValue(objects: RealtimeObjects, objectId: string): LiveCounter { - return new LiveCounter(objects, objectId); + static zeroValue(realtimeObject: RealtimeObject, objectId: string): LiveCounter { + return new LiveCounter(realtimeObject, objectId); } /** @@ -36,8 +36,8 @@ export class LiveCounter extends LiveObject * * @internal */ - static fromObjectState(objects: RealtimeObjects, objectMessage: ObjectMessage): LiveCounter { - const obj = new LiveCounter(objects, objectMessage.object!.objectId); + static fromObjectState(realtimeObject: RealtimeObject, objectMessage: ObjectMessage): LiveCounter { + const obj = new LiveCounter(realtimeObject, objectMessage.object!.objectId); obj.overrideWithObjectState(objectMessage); return obj; } @@ -48,8 +48,8 @@ export class LiveCounter extends LiveObject * * @internal */ - static fromObjectOperation(objects: RealtimeObjects, objectMessage: ObjectMessage): LiveCounter { - const obj = new LiveCounter(objects, objectMessage.operation!.objectId); + static fromObjectOperation(realtimeObject: RealtimeObject, objectMessage: ObjectMessage): LiveCounter { + const obj = new LiveCounter(realtimeObject, objectMessage.operation!.objectId); obj._mergeInitialDataFromCreateOperation(objectMessage.operation!, objectMessage); return obj; } @@ -57,8 +57,8 @@ export class LiveCounter extends LiveObject /** * @internal */ - static createCounterIncMessage(objects: RealtimeObjects, objectId: string, amount: number): ObjectMessage { - const client = objects.getClient(); + static createCounterIncMessage(realtimeObject: RealtimeObject, objectId: string, amount: number): ObjectMessage { + const client = realtimeObject.getClient(); if (typeof amount !== 'number' || !Number.isFinite(amount)) { throw new client.ErrorInfo('Counter value increment should be a valid number', 40003, 400); @@ -82,8 +82,8 @@ export class LiveCounter extends LiveObject /** * @internal */ - static async createCounterCreateMessage(objects: RealtimeObjects, count?: number): Promise { - const client = objects.getClient(); + static async createCounterCreateMessage(realtimeObject: RealtimeObject, count?: number): Promise { + const client = realtimeObject.getClient(); if (count !== undefined && (typeof count !== 'number' || !Number.isFinite(count))) { throw new client.ErrorInfo('Counter value should be a valid number', 40003, 400); @@ -132,7 +132,7 @@ export class LiveCounter extends LiveObject /** @spec RTLC5 */ value(): number { - this._objects.throwIfInvalidAccessApiConfiguration(); // RTLC5a, RTLC5b + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); // RTLC5a, RTLC5b return this._dataRef.data; // RTLC5c } @@ -146,16 +146,16 @@ export class LiveCounter extends LiveObject * @returns A promise which resolves upon receiving the ACK message for the published operation message. */ async increment(amount: number): Promise { - this._objects.throwIfInvalidWriteApiConfiguration(); - const msg = LiveCounter.createCounterIncMessage(this._objects, this.getObjectId(), amount); - return this._objects.publish([msg]); + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); + const msg = LiveCounter.createCounterIncMessage(this._realtimeObject, this.getObjectId(), amount); + return this._realtimeObject.publish([msg]); } /** * An alias for calling {@link LiveCounter.increment | LiveCounter.increment(-amount)} */ async decrement(amount: number): Promise { - this._objects.throwIfInvalidWriteApiConfiguration(); + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); // do an explicit type safety check here before negating the amount value, // so we don't unintentionally change the type sent by a user if (typeof amount !== 'number' || !Number.isFinite(amount)) { diff --git a/src/plugins/objects/livemap.ts b/src/plugins/objects/livemap.ts index d9e821c928..b663fa28a3 100644 --- a/src/plugins/objects/livemap.ts +++ b/src/plugins/objects/livemap.ts @@ -15,7 +15,7 @@ import { ObjectsMapSemantics, PrimitiveObjectValue, } from './objectmessage'; -import { RealtimeObjects } from './realtimeobjects'; +import { RealtimeObject } from './realtimeobject'; export interface ObjectIdObjectData { /** A reference to another object, used to support composable object structures. */ @@ -47,11 +47,11 @@ export interface LiveMapUpdate extends LiveObjectUpda /** @spec RTLM1, RTLM2 */ export class LiveMap extends LiveObject> { constructor( - objects: RealtimeObjects, + realtimeObject: RealtimeObject, private _semantics: ObjectsMapSemantics, objectId: string, ) { - super(objects, objectId); + super(realtimeObject, objectId); } /** @@ -60,8 +60,8 @@ export class LiveMap extends LiveObject(objects: RealtimeObjects, objectId: string): LiveMap { - return new LiveMap(objects, ObjectsMapSemantics.LWW, objectId); + static zeroValue(realtimeObject: RealtimeObject, objectId: string): LiveMap { + return new LiveMap(realtimeObject, ObjectsMapSemantics.LWW, objectId); } /** @@ -71,10 +71,10 @@ export class LiveMap extends LiveObject( - objects: RealtimeObjects, + realtimeObject: RealtimeObject, objectMessage: ObjectMessage, ): LiveMap { - const obj = new LiveMap(objects, objectMessage.object!.map!.semantics!, objectMessage.object!.objectId); + const obj = new LiveMap(realtimeObject, objectMessage.object!.map!.semantics!, objectMessage.object!.objectId); obj.overrideWithObjectState(objectMessage); return obj; } @@ -86,10 +86,14 @@ export class LiveMap extends LiveObject( - objects: RealtimeObjects, + realtimeObject: RealtimeObject, objectMessage: ObjectMessage, ): LiveMap { - const obj = new LiveMap(objects, objectMessage.operation!.map?.semantics!, objectMessage.operation!.objectId); + const obj = new LiveMap( + realtimeObject, + objectMessage.operation!.map?.semantics!, + objectMessage.operation!.objectId, + ); obj._mergeInitialDataFromCreateOperation(objectMessage.operation!, objectMessage); return obj; } @@ -98,14 +102,14 @@ export class LiveMap extends LiveObject( - objects: RealtimeObjects, + realtimeObject: RealtimeObject, objectId: string, key: TKey, value: API.LiveMapType[TKey], ): ObjectMessage { - const client = objects.getClient(); + const client = realtimeObject.getClient(); - LiveMap.validateKeyValue(objects, key, value); + LiveMap.validateKeyValue(realtimeObject, key, value); let objectData: LiveMapObjectData; if (value instanceof LiveObject) { @@ -138,11 +142,11 @@ export class LiveMap extends LiveObject( - objects: RealtimeObjects, + realtimeObject: RealtimeObject, objectId: string, key: TKey, ): ObjectMessage { - const client = objects.getClient(); + const client = realtimeObject.getClient(); if (typeof key !== 'string') { throw new client.ErrorInfo('Map key should be string', 40003, 400); @@ -167,11 +171,11 @@ export class LiveMap extends LiveObject( - objects: RealtimeObjects, + realtimeObject: RealtimeObject, key: TKey, value: API.LiveMapType[TKey], ): void { - const client = objects.getClient(); + const client = realtimeObject.getClient(); if (typeof key !== 'string') { throw new client.ErrorInfo('Map key should be string', 40003, 400); @@ -191,14 +195,17 @@ export class LiveMap extends LiveObject { - const client = objects.getClient(); + static async createMapCreateMessage( + realtimeObject: RealtimeObject, + entries?: API.LiveMapType, + ): Promise { + const client = realtimeObject.getClient(); if (entries !== undefined && (entries === null || typeof entries !== 'object')) { throw new client.ErrorInfo('Map entries should be a key-value object', 40003, 400); } - Object.entries(entries ?? {}).forEach(([key, value]) => LiveMap.validateKeyValue(objects, key, value)); + Object.entries(entries ?? {}).forEach(([key, value]) => LiveMap.validateKeyValue(realtimeObject, key, value)); const initialValueOperation = LiveMap.createInitialValueOperation(entries); const initialValueJSONString = createInitialValueJSONString(initialValueOperation, client); @@ -273,7 +280,7 @@ export class LiveMap extends LiveObject(key: TKey): T[TKey] | undefined { - this._objects.throwIfInvalidAccessApiConfiguration(); // RTLM5b, RTLM5c + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); // RTLM5b, RTLM5c if (this.isTombstoned()) { return undefined as T[TKey]; @@ -296,7 +303,7 @@ export class LiveMap extends LiveObject extends LiveObject(): IterableIterator<[TKey, T[TKey]]> { - this._objects.throwIfInvalidAccessApiConfiguration(); + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); for (const [key, entry] of this._dataRef.data.entries()) { if (this._isMapEntryTombstoned(entry)) { @@ -348,9 +355,9 @@ export class LiveMap extends LiveObject(key: TKey, value: T[TKey]): Promise { - this._objects.throwIfInvalidWriteApiConfiguration(); - const msg = LiveMap.createMapSetMessage(this._objects, this.getObjectId(), key, value); - return this._objects.publish([msg]); + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); + const msg = LiveMap.createMapSetMessage(this._realtimeObject, this.getObjectId(), key, value); + return this._realtimeObject.publish([msg]); } /** @@ -363,9 +370,9 @@ export class LiveMap extends LiveObject(key: TKey): Promise { - this._objects.throwIfInvalidWriteApiConfiguration(); - const msg = LiveMap.createMapRemoveMessage(this._objects, this.getObjectId(), key); - return this._objects.publish([msg]); + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); + const msg = LiveMap.createMapRemoveMessage(this._realtimeObject, this.getObjectId(), key); + return this._realtimeObject.publish([msg]); } /** @@ -533,7 +540,7 @@ export class LiveMap extends LiveObject= this._objects.gcGracePeriod) { + if (value.tombstone === true && Date.now() - value.tombstonedAt! >= this._realtimeObject.gcGracePeriod) { keysToDelete.push(key); } } @@ -715,7 +722,7 @@ export class LiveMap extends LiveObject extends LiveObject extends LiveObject void): SubscribeResponse { - this._objects.throwIfInvalidAccessApiConfiguration(); + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); this._subscriptions.on(LiveObjectSubscriptionEvent.updated, listener); diff --git a/src/plugins/objects/objectspool.ts b/src/plugins/objects/objectspool.ts index e76e873e0e..4c7fa05e0c 100644 --- a/src/plugins/objects/objectspool.ts +++ b/src/plugins/objects/objectspool.ts @@ -4,7 +4,7 @@ import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; import { LiveObject } from './liveobject'; import { ObjectId } from './objectid'; -import { RealtimeObjects } from './realtimeobjects'; +import { RealtimeObject } from './realtimeobject'; export const ROOT_OBJECT_ID = 'root'; @@ -17,8 +17,8 @@ export class ObjectsPool { private _pool: Map; // RTO3a private _gcInterval: ReturnType; - constructor(private _objects: RealtimeObjects) { - this._client = this._objects.getClient(); + constructor(private _realtimeObject: RealtimeObject) { + this._client = this._realtimeObject.getClient(); this._pool = this._createInitialPool(); this._gcInterval = setInterval(() => { this._onGCInterval(); @@ -82,12 +82,12 @@ export class ObjectsPool { let zeroValueObject: LiveObject; switch (parsedObjectId.type) { case 'map': { - zeroValueObject = LiveMap.zeroValue(this._objects, objectId); // RTO6b2 + zeroValueObject = LiveMap.zeroValue(this._realtimeObject, objectId); // RTO6b2 break; } case 'counter': - zeroValueObject = LiveCounter.zeroValue(this._objects, objectId); // RTO6b3 + zeroValueObject = LiveCounter.zeroValue(this._realtimeObject, objectId); // RTO6b3 break; } @@ -98,7 +98,7 @@ export class ObjectsPool { private _createInitialPool(): Map { const pool = new Map(); // RTO3b - const root = LiveMap.zeroValue(this._objects, ROOT_OBJECT_ID); + const root = LiveMap.zeroValue(this._realtimeObject, ROOT_OBJECT_ID); pool.set(root.getObjectId(), root); return pool; } @@ -109,7 +109,7 @@ export class ObjectsPool { // tombstoned objects should be removed from the pool if they have been tombstoned for longer than grace period. // by removing them from the local pool, Objects plugin no longer keeps a reference to those objects, allowing JS's // Garbage Collection to eventually free the memory for those objects, provided the user no longer references them either. - if (obj.isTombstoned() && Date.now() - obj.tombstonedAt()! >= this._objects.gcGracePeriod) { + if (obj.isTombstoned() && Date.now() - obj.tombstonedAt()! >= this._realtimeObject.gcGracePeriod) { toDelete.push(objectId); continue; } diff --git a/src/plugins/objects/realtimeobjects.ts b/src/plugins/objects/realtimeobject.ts similarity index 97% rename from src/plugins/objects/realtimeobjects.ts rename to src/plugins/objects/realtimeobject.ts index c74cc0bcc3..62b7f67ba6 100644 --- a/src/plugins/objects/realtimeobjects.ts +++ b/src/plugins/objects/realtimeobject.ts @@ -36,7 +36,7 @@ export interface OnObjectsEventResponse { export type BatchCallback = (batchContext: BatchContext) => void; -export class RealtimeObjects { +export class RealtimeObject { gcGracePeriod: number; private _client: BaseClient; @@ -75,11 +75,11 @@ export class RealtimeObjects { /** * When called without a type variable, we return a default root type which is based on globally defined interface for Objects feature. - * A user can provide an explicit type for the getRoot method to explicitly set the type structure on this particular channel. + * A user can provide an explicit type for the this method to explicitly set the type structure on this particular channel. * This is useful when working with multiple channels with different underlying data structure. * @spec RTO1 */ - async getRoot(): Promise> { + async get(): Promise> { this.throwIfInvalidAccessApiConfiguration(); // RTO1a, RTO1b // if we're not synced yet, wait for sync sequence to finish before returning root @@ -96,7 +96,7 @@ export class RealtimeObjects { async batch(callback: BatchCallback): Promise { this.throwIfInvalidWriteApiConfiguration(); - const root = await this.getRoot(); + const root = await this.get(); const context = new BatchContext(this, root); try { @@ -263,7 +263,7 @@ export class RealtimeObjects { this._client.Logger.logAction( this._client.logger, this._client.Logger.LOG_MINOR, - 'RealtimeObjects.onAttached()', + 'RealtimeObject.onAttached()', `channel=${this._channel.name}, hasObjects=${hasObjects}`, ); @@ -432,7 +432,7 @@ export class RealtimeObjects { this._client.Logger.logAction( this._client.logger, this._client.Logger.LOG_MAJOR, - 'RealtimeObjects._applyObjectMessages()', + 'RealtimeObject._applyObjectMessages()', `object operation message is received without 'operation' field, skipping message; message id: ${objectMessage.id}, channel: ${this._channel.name}`, ); continue; @@ -461,7 +461,7 @@ export class RealtimeObjects { this._client.Logger.logAction( this._client.logger, this._client.Logger.LOG_MAJOR, - 'RealtimeObjects._applyObjectMessages()', + 'RealtimeObject._applyObjectMessages()', `received unsupported action in object operation message: ${objectOperation.action}, skipping message; message id: ${objectMessage.id}, channel: ${this._channel.name}`, ); } diff --git a/src/plugins/objects/syncobjectsdatapool.ts b/src/plugins/objects/syncobjectsdatapool.ts index 66dd5d36e3..31f6eeb5c8 100644 --- a/src/plugins/objects/syncobjectsdatapool.ts +++ b/src/plugins/objects/syncobjectsdatapool.ts @@ -1,7 +1,7 @@ import type BaseClient from 'common/lib/client/baseclient'; import type RealtimeChannel from 'common/lib/client/realtimechannel'; import { ObjectMessage } from './objectmessage'; -import { RealtimeObjects } from './realtimeobjects'; +import { RealtimeObject } from './realtimeobject'; export interface LiveObjectDataEntry { objectMessage: ObjectMessage; @@ -27,9 +27,9 @@ export class SyncObjectsDataPool { private _channel: RealtimeChannel; private _pool: Map; - constructor(private _objects: RealtimeObjects) { - this._client = this._objects.getClient(); - this._channel = this._objects.getChannel(); + constructor(private _realtimeObject: RealtimeObject) { + this._client = this._realtimeObject.getClient(); + this._channel = this._realtimeObject.getChannel(); this._pool = new Map(); } diff --git a/test/common/modules/private_api_recorder.js b/test/common/modules/private_api_recorder.js index 15434cda14..4498a45414 100644 --- a/test/common/modules/private_api_recorder.js +++ b/test/common/modules/private_api_recorder.js @@ -26,8 +26,8 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths) 'call.Platform.nextTick', 'call.PresenceMessage.fromValues', 'call.ProtocolMessage.setFlag', - 'call.RealtimeObjects._objectsPool._onGCInterval', - 'call.RealtimeObjects._objectsPool.get', + 'call.RealtimeObject._objectsPool._onGCInterval', + 'call.RealtimeObject._objectsPool.get', 'call.Utils.copy', 'call.Utils.dataSizeBytes', 'call.Utils.getRetryTime', @@ -83,8 +83,8 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths) 'read.Platform.Config.push', 'read.ProtocolMessage.channelSerial', 'read.Realtime._transports', - 'read.RealtimeObjects._DEFAULTS.gcGracePeriod', - 'read.RealtimeObjects.gcGracePeriod', + 'read.RealtimeObject._DEFAULTS.gcGracePeriod', + 'read.RealtimeObject.gcGracePeriod', 'read.auth.authOptions.authUrl', 'read.auth.key', 'read.auth.method', @@ -124,8 +124,8 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths) 'read.transport.params.mode', 'read.transport.recvRequest.recvUri', 'read.transport.uri', - 'replace.RealtimeObjects._objectsPool._onGCInterval', - 'replace.RealtimeObjects.publish', + 'replace.RealtimeObject._objectsPool._onGCInterval', + 'replace.RealtimeObject.publish', 'replace.channel.attachImpl', 'replace.channel.processMessage', 'replace.channel.sendMessage', @@ -144,8 +144,8 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths) 'write.Defaults.ENVIRONMENT', 'write.Defaults.wsConnectivityCheckUrl', 'write.Platform.Config.push', // This implies using a mock implementation of the internal IPlatformPushConfig interface. Our mock (in push_channel_transport.js) then interacts with internal objects and private APIs of public objects to implement this interface; I haven’t added annotations for that private API usage, since there wasn’t an easy way to pass test context information into the mock. I think that for now we can just say that if we wanted to get rid of this private API usage, then we’d need to remove this mock entirely. - 'write.RealtimeObjects._DEFAULTS.gcInterval', - 'write.RealtimeObjects.gcGracePeriod', + 'write.RealtimeObject._DEFAULTS.gcInterval', + 'write.RealtimeObject.gcGracePeriod', 'write.auth.authOptions.requestHeaders', 'write.auth.key', 'write.auth.tokenDetails.token', diff --git a/test/package/browser/template/src/index-objects.ts b/test/package/browser/template/src/index-objects.ts index a9442a3fe7..7bfc5fb0a0 100644 --- a/test/package/browser/template/src/index-objects.ts +++ b/test/package/browser/template/src/index-objects.ts @@ -39,11 +39,10 @@ globalThis.testAblyPackage = async function () { const channel = realtime.channels.get('channel', { modes: ['OBJECT_SUBSCRIBE', 'OBJECT_PUBLISH'] }); // check Objects can be accessed - const objects = channel.objects; await channel.attach(); // expect root to be a LiveMap instance with Objects types defined via the global AblyObjectsTypes interface // also checks that we can refer to the Objects types exported from 'ably' by referencing a LiveMap interface - const root: Ably.LiveMap = await objects.getRoot(); + const root: Ably.LiveMap = await channel.object.get(); // check root has expected LiveMap TypeScript type methods const size: number = root.size(); @@ -89,7 +88,7 @@ globalThis.testAblyPackage = async function () { }); counterSubscribeResponse?.unsubscribe(); - // check can provide custom types for the getRoot method, ignoring global AblyObjectsTypes interface - const explicitRoot: Ably.LiveMap = await objects.getRoot(); + // check can provide custom types for the object.get() method, ignoring global AblyObjectsTypes interface + const explicitRoot: Ably.LiveMap = await channel.object.get(); const someOtherKey: string | undefined = explicitRoot.get('someOtherKey'); }; diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js index 4c45fc15cd..aabb649be8 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/objects.test.js @@ -14,7 +14,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const createPM = Ably.makeProtocolMessageFromDeserialized({ ObjectsPlugin }); const objectsFixturesChannel = 'objects_fixtures'; const nextTick = Ably.Realtime.Platform.Config.nextTick; - const gcIntervalOriginal = ObjectsPlugin.RealtimeObjects._DEFAULTS.gcInterval; + const gcIntervalOriginal = ObjectsPlugin.RealtimeObject._DEFAULTS.gcInterval; function RealtimeWithObjects(helper, options) { return helper.AblyRealtime({ ...options, plugins: { Objects: ObjectsPlugin } }); @@ -164,11 +164,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function */ async function waitFixtureChannelIsReady(client) { const channel = client.channels.get(objectsFixturesChannel, channelOptionsWithObjects()); - const objects = channel.objects; const expectedKeys = ObjectsHelper.fixtureRootKeys(); await channel.attach(); - const root = await objects.getRoot(); + const root = await channel.object.get(); await Promise.all(expectedKeys.map((key) => (root.get(key) ? undefined : waitForMapKeyUpdate(root, key)))); } @@ -194,11 +193,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function describe('Realtime without Objects plugin', () => { /** @nospec */ - it("throws an error when attempting to access the channel's `objects` property", async function () { + it("throws an error when attempting to access the channel's `object` property", async function () { const helper = this.test.helper; const client = helper.AblyRealtime({ autoConnect: false }); const channel = client.channels.get('channel'); - expect(() => channel.objects).to.throw('Objects plugin not provided'); + expect(() => channel.object).to.throw('Objects plugin not provided'); }); /** @nospec */ @@ -272,40 +271,38 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function describe('Realtime with Objects plugin', () => { /** @nospec */ - it("returns RealtimeObjects class instance when accessing channel's `objects` property", async function () { + it("returns RealtimeObject class instance when accessing channel's `object` property", async function () { const helper = this.test.helper; const client = RealtimeWithObjects(helper, { autoConnect: false }); const channel = client.channels.get('channel'); - expectInstanceOf(channel.objects, 'RealtimeObjects'); + expectInstanceOf(channel.object, 'RealtimeObject'); }); /** @nospec */ - it('getRoot() returns LiveMap instance', async function () { + it('RealtimeObject.get() returns LiveMap instance', async function () { const helper = this.test.helper; const client = RealtimeWithObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { const channel = client.channels.get('channel', channelOptionsWithObjects()); - const objects = channel.objects; await channel.attach(); - const root = await objects.getRoot(); + const root = await channel.object.get(); expectInstanceOf(root, 'LiveMap', 'root object should be of LiveMap type'); }, client); }); /** @nospec */ - it('getRoot() returns LiveObject with id "root"', async function () { + it('RealtimeObject.get() returns LiveObject with id "root"', async function () { const helper = this.test.helper; const client = RealtimeWithObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { const channel = client.channels.get('channel', channelOptionsWithObjects()); - const objects = channel.objects; await channel.attach(); - const root = await objects.getRoot(); + const root = await channel.object.get(); helper.recordPrivateApi('call.LiveObject.getObjectId'); expect(root.getObjectId()).to.equal('root', 'root object should have an object id "root"'); @@ -313,89 +310,85 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); /** @nospec */ - it('getRoot() returns empty root when no objects exist on a channel', async function () { + it('RealtimeObject.get() returns empty root when no objects exist on a channel', async function () { const helper = this.test.helper; const client = RealtimeWithObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { const channel = client.channels.get('channel', channelOptionsWithObjects()); - const objects = channel.objects; await channel.attach(); - const root = await objects.getRoot(); + const root = await channel.object.get(); expect(root.size()).to.equal(0, 'Check root has no keys'); }, client); }); /** @nospec */ - it('getRoot() waits for initial OBJECT_SYNC to be completed before resolving', async function () { + it('RealtimeObject.get() waits for initial OBJECT_SYNC to be completed before resolving', async function () { const helper = this.test.helper; const client = RealtimeWithObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { const channel = client.channels.get('channel', channelOptionsWithObjects()); - const objects = channel.objects; - const getRootPromise = objects.getRoot(); + const getPromise = channel.object.get(); - let getRootResolved = false; - getRootPromise.then(() => { - getRootResolved = true; + let getResolved = false; + getPromise.then(() => { + getResolved = true; }); - // give a chance for getRoot() to resolve and proc its handler. it should not + // give a chance for RealtimeObject.get() to resolve and proc its handler. it should not helper.recordPrivateApi('call.Platform.nextTick'); await new Promise((res) => nextTick(res)); - expect(getRootResolved, 'Check getRoot() is not resolved until OBJECT_SYNC sequence is completed').to.be - .false; + expect(getResolved, 'Check RealtimeObject.get() is not resolved until OBJECT_SYNC sequence is completed').to + .be.false; await channel.attach(); // should resolve eventually after attach - await getRootPromise; + await getPromise; }, client); }); /** @nospec */ - it('getRoot() resolves immediately when OBJECT_SYNC sequence is completed', async function () { + it('RealtimeObject.get() resolves immediately when OBJECT_SYNC sequence is completed', async function () { const helper = this.test.helper; const client = RealtimeWithObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { const channel = client.channels.get('channel', channelOptionsWithObjects()); - const objects = channel.objects; await channel.attach(); // wait for sync sequence to complete by accessing root for the first time - await objects.getRoot(); + await channel.object.get(); let resolvedImmediately = false; - objects.getRoot().then(() => { + channel.object.get().then(() => { resolvedImmediately = true; }); - // wait for next tick for getRoot() handler to process + // wait for next tick for RealtimeObject.get() handler to process helper.recordPrivateApi('call.Platform.nextTick'); await new Promise((res) => nextTick(res)); - expect(resolvedImmediately, 'Check getRoot() is resolved on next tick').to.be.true; + expect(resolvedImmediately, 'Check RealtimeObject.get() is resolved on next tick').to.be.true; }, client); }); /** @nospec */ - it('getRoot() waits for OBJECT_SYNC with empty cursor before resolving', async function () { + it('RealtimeObject.get() waits for OBJECT_SYNC with empty cursor before resolving', async function () { const helper = this.test.helper; const objectsHelper = new ObjectsHelper(helper); const client = RealtimeWithObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { const channel = client.channels.get('channel', channelOptionsWithObjects()); - const objects = channel.objects; await channel.attach(); // wait for initial sync sequence to complete - await objects.getRoot(); + await channel.object.get(); // inject OBJECT_SYNC message to emulate start of a new sequence await objectsHelper.processObjectStateMessageOnChannel({ @@ -404,18 +397,19 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function syncSerial: 'serial:cursor', }); - let getRootResolved = false; + let getResolved = false; let root; - objects.getRoot().then((value) => { - getRootResolved = true; + channel.object.get().then((value) => { + getResolved = true; root = value; }); - // wait for next tick to check that getRoot() promise handler didn't proc + // wait for next tick to check that RealtimeObject.get() promise handler didn't proc helper.recordPrivateApi('call.Platform.nextTick'); await new Promise((res) => nextTick(res)); - expect(getRootResolved, 'Check getRoot() is not resolved while OBJECT_SYNC is in progress').to.be.false; + expect(getResolved, 'Check RealtimeObject.get() is not resolved while OBJECT_SYNC is in progress').to.be + .false; // inject final OBJECT_SYNC message await objectsHelper.processObjectStateMessageOnChannel({ @@ -431,11 +425,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ], }); - // wait for next tick for getRoot() handler to process + // wait for next tick for RealtimeObject.get() handler to process helper.recordPrivateApi('call.Platform.nextTick'); await new Promise((res) => nextTick(res)); - expect(getRootResolved, 'Check getRoot() is resolved when OBJECT_SYNC sequence has ended').to.be.true; + expect(getResolved, 'Check RealtimeObject.get() is resolved when OBJECT_SYNC sequence has ended').to.be.true; expect(root.get('key')).to.equal(1, 'Check new root after OBJECT_SYNC sequence has expected key'); }, client); }); @@ -502,16 +496,15 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await waitFixtureChannelIsReady(client); const channel = client.channels.get(objectsFixturesChannel, channelOptionsWithObjects()); - const objects = channel.objects; await channel.attach(); - const root = await objects.getRoot(); + const root = await channel.object.get(); const counterKeys = ['emptyCounter', 'initialValueCounter', 'referencedCounter']; const mapKeys = ['emptyMap', 'referencedMap', 'valuesMap']; const rootKeysCount = counterKeys.length + mapKeys.length; - expect(root, 'Check getRoot() is resolved when OBJECT_SYNC sequence ends').to.exist; + expect(root, 'Check RealtimeObject.get() is resolved when OBJECT_SYNC sequence ends').to.exist; expect(root.size()).to.equal(rootKeysCount, 'Check root has correct number of keys'); counterKeys.forEach((key) => { @@ -554,7 +547,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'OBJECT_SYNC sequence builds object tree with all operations applied', action: async (ctx) => { - const { root, objects, helper, clientOptions, channelName } = ctx; + const { root, realtimeObject, helper, clientOptions, channelName } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'counter'), @@ -562,9 +555,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ]); // MAP_CREATE - const map = await objects.createMap({ shouldStay: 'foo', shouldDelete: 'bar' }); + const map = await realtimeObject.createMap({ shouldStay: 'foo', shouldDelete: 'bar' }); // COUNTER_CREATE - const counter = await objects.createCounter(1); + const counter = await realtimeObject.createCounter(1); await Promise.all([root.set('map', map), root.set('counter', counter), objectsCreatedPromise]); @@ -589,10 +582,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await helper.monitorConnectionThenCloseAndFinishAsync(async () => { const channel2 = client2.channels.get(channelName, channelOptionsWithObjects()); - const objects2 = channel2.objects; await channel2.attach(); - const root2 = await objects2.getRoot(); + const root2 = await channel2.object.get(); expect(root2.get('counter'), 'Check counter exists').to.exist; expect(root2.get('counter').value()).to.equal(11, 'Check counter has correct value'); @@ -615,15 +607,15 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'OBJECT_SYNC sequence does not change references to existing objects', action: async (ctx) => { - const { root, objects, helper, channel } = ctx; + const { root, realtimeObject, helper, channel } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'map'), ]); - const map = await objects.createMap(); - const counter = await objects.createCounter(); + const map = await realtimeObject.createMap(); + const counter = await realtimeObject.createCounter(); await Promise.all([root.set('map', map), root.set('counter', counter), objectsCreatedPromise]); await channel.detach(); @@ -632,7 +624,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await channel.attach(); await objectSyncPromise; - const newRootRef = await channel.objects.getRoot(); + const newRootRef = await channel.object.get(); const newMapRef = newRootRef.get('map'); const newCounterRef = newRootRef.get('counter'); @@ -651,10 +643,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await waitFixtureChannelIsReady(client); const channel = client.channels.get(objectsFixturesChannel, channelOptionsWithObjects()); - const objects = channel.objects; await channel.attach(); - const root = await objects.getRoot(); + const root = await channel.object.get(); const counters = [ { key: 'emptyCounter', value: 0 }, @@ -678,10 +669,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await waitFixtureChannelIsReady(client); const channel = client.channels.get(objectsFixturesChannel, channelOptionsWithObjects()); - const objects = channel.objects; await channel.attach(); - const root = await objects.getRoot(); + const root = await channel.object.get(); const emptyMap = root.get('emptyMap'); expect(emptyMap.size()).to.equal(0, 'Check empty map in root has no keys'); @@ -747,10 +737,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await waitFixtureChannelIsReady(client); const channel = client.channels.get(objectsFixturesChannel, channelOptionsWithObjects()); - const objects = channel.objects; await channel.attach(); - const root = await objects.getRoot(); + const root = await channel.object.get(); const referencedCounter = root.get('referencedCounter'); const referencedMap = root.get('referencedMap'); @@ -941,7 +930,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'OBJECT_SYNC sequence with "tombstone=true" for an object sets "tombstoneAt" from "serialTimestamp"', action: async (ctx) => { - const { helper, objectsHelper, channel, objects } = ctx; + const { helper, objectsHelper, channel, realtimeObject } = ctx; const counterId = objectsHelper.fakeCounterObjectId(); const serialTimestamp = 1234567890; @@ -968,8 +957,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ], }); - helper.recordPrivateApi('call.RealtimeObjects._objectsPool.get'); - const obj = objects._objectsPool.get(counterId); + helper.recordPrivateApi('call.RealtimeObject._objectsPool.get'); + const obj = realtimeObject._objectsPool.get(counterId); expect(obj, 'Check object added to the pool OBJECT_SYNC sequence with "tombstone=true"').to.exist; helper.recordPrivateApi('call.LiveObject.tombstonedAt'); expect(obj.tombstonedAt()).to.equal( @@ -983,7 +972,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'OBJECT_SYNC sequence with "tombstone=true" for an object sets "tombstoneAt" using local clock if missing "serialTimestamp"', action: async (ctx) => { - const { helper, objectsHelper, channel, objects } = ctx; + const { helper, objectsHelper, channel, realtimeObject } = ctx; const tsBeforeMsg = Date.now(); const counterId = objectsHelper.fakeCounterObjectId(); @@ -1011,8 +1000,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); const tsAfterMsg = Date.now(); - helper.recordPrivateApi('call.RealtimeObjects._objectsPool.get'); - const obj = objects._objectsPool.get(counterId); + helper.recordPrivateApi('call.RealtimeObject._objectsPool.get'); + const obj = realtimeObject._objectsPool.get(counterId); expect(obj, 'Check object added to the pool OBJECT_SYNC sequence with "tombstone=true"').to.exist; helper.recordPrivateApi('call.LiveObject.tombstonedAt'); expect( @@ -2132,7 +2121,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'OBJECT_DELETE for an object sets "tombstoneAt" from "serialTimestamp"', action: async (ctx) => { - const { root, objectsHelper, channelName, channel, helper, objects } = ctx; + const { root, objectsHelper, channelName, channel, helper, realtimeObject } = ctx; const objectCreatedPromise = waitForMapKeyUpdate(root, 'object'); const { objectId } = await objectsHelper.createAndSetOnMap(channelName, { @@ -2154,8 +2143,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function state: [objectsHelper.objectDeleteOp({ objectId })], }); - helper.recordPrivateApi('call.RealtimeObjects._objectsPool.get'); - const obj = objects._objectsPool.get(objectId); + helper.recordPrivateApi('call.RealtimeObject._objectsPool.get'); + const obj = realtimeObject._objectsPool.get(objectId); helper.recordPrivateApi('call.LiveObject.isTombstoned'); expect(obj.isTombstoned()).to.equal(true, `Check object is tombstoned after OBJECT_DELETE`); helper.recordPrivateApi('call.LiveObject.tombstonedAt'); @@ -2169,7 +2158,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'OBJECT_DELETE for an object sets "tombstoneAt" using local clock if missing "serialTimestamp"', action: async (ctx) => { - const { root, objectsHelper, channelName, channel, helper, objects } = ctx; + const { root, objectsHelper, channelName, channel, helper, realtimeObject } = ctx; const objectCreatedPromise = waitForMapKeyUpdate(root, 'object'); const { objectId } = await objectsHelper.createAndSetOnMap(channelName, { @@ -2192,8 +2181,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); const tsAfterMsg = Date.now(); - helper.recordPrivateApi('call.RealtimeObjects._objectsPool.get'); - const obj = objects._objectsPool.get(objectId); + helper.recordPrivateApi('call.RealtimeObject._objectsPool.get'); + const obj = realtimeObject._objectsPool.get(objectId); helper.recordPrivateApi('call.LiveObject.isTombstoned'); expect(obj.isTombstoned()).to.equal(true, `Check object is tombstoned after OBJECT_DELETE`); helper.recordPrivateApi('call.LiveObject.tombstonedAt'); @@ -3076,11 +3065,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { allTransportsAndProtocols: true, - description: 'RealtimeObjects.createCounter sends COUNTER_CREATE operation', + description: 'RealtimeObject.createCounter sends COUNTER_CREATE operation', action: async (ctx) => { - const { objects } = ctx; + const { realtimeObject } = ctx; - const counters = await Promise.all(countersFixtures.map(async (x) => objects.createCounter(x.count))); + const counters = await Promise.all( + countersFixtures.map(async (x) => realtimeObject.createCounter(x.count)), + ); for (let i = 0; i < counters.length; i++) { const counter = counters[i]; @@ -3098,12 +3089,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { allTransportsAndProtocols: true, - description: 'LiveCounter created with RealtimeObjects.createCounter can be assigned to the object tree', + description: 'LiveCounter created with RealtimeObject.createCounter can be assigned to the object tree', action: async (ctx) => { - const { root, objects } = ctx; + const { root, realtimeObject } = ctx; const counterCreatedPromise = waitForMapKeyUpdate(root, 'counter'); - const counter = await objects.createCounter(1); + const counter = await realtimeObject.createCounter(1); await root.set('counter', counter); await counterCreatedPromise; @@ -3126,15 +3117,15 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: - 'RealtimeObjects.createCounter can return LiveCounter with initial value without applying CREATE operation', + 'RealtimeObject.createCounter can return LiveCounter with initial value without applying CREATE operation', action: async (ctx) => { - const { objects, helper } = ctx; + const { realtimeObject, helper } = ctx; // prevent publishing of ops to realtime so we guarantee that the initial value doesn't come from a CREATE op - helper.recordPrivateApi('replace.RealtimeObjects.publish'); - objects.publish = () => {}; + helper.recordPrivateApi('replace.RealtimeObject.publish'); + realtimeObject.publish = () => {}; - const counter = await objects.createCounter(1); + const counter = await realtimeObject.createCounter(1); expect(counter.value()).to.equal(1, `Check counter has expected initial value`); }, }, @@ -3142,14 +3133,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { allTransportsAndProtocols: true, description: - 'RealtimeObjects.createCounter can return LiveCounter with initial value from applied CREATE operation', + 'RealtimeObject.createCounter can return LiveCounter with initial value from applied CREATE operation', action: async (ctx) => { - const { objects, objectsHelper, helper, channel } = ctx; + const { realtimeObject, objectsHelper, helper, channel } = ctx; // instead of sending CREATE op to the realtime, echo it immediately to the client // with forged initial value so we can check that counter gets initialized with a value from a CREATE op - helper.recordPrivateApi('replace.RealtimeObjects.publish'); - objects.publish = async (objectMessages) => { + helper.recordPrivateApi('replace.RealtimeObject.publish'); + realtimeObject.publish = async (objectMessages) => { const counterId = objectMessages[0].operation.objectId; // this should result execute regular operation application procedure and create an object in the pool with forged initial value await objectsHelper.processObjectOperationMessageOnChannel({ @@ -3160,7 +3151,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); }; - const counter = await objects.createCounter(1); + const counter = await realtimeObject.createCounter(1); // counter should be created with forged initial value instead of the actual one expect(counter.value()).to.equal( @@ -3172,16 +3163,16 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: - 'initial value is not double counted for LiveCounter from RealtimeObjects.createCounter when CREATE op is received', + 'initial value is not double counted for LiveCounter from RealtimeObject.createCounter when CREATE op is received', action: async (ctx) => { - const { objects, objectsHelper, helper, channel } = ctx; + const { realtimeObject, objectsHelper, helper, channel } = ctx; // prevent publishing of ops to realtime so we can guarantee order of operations - helper.recordPrivateApi('replace.RealtimeObjects.publish'); - objects.publish = () => {}; + helper.recordPrivateApi('replace.RealtimeObject.publish'); + realtimeObject.publish = () => {}; // create counter locally, should have an initial value set - const counter = await objects.createCounter(1); + const counter = await realtimeObject.createCounter(1); helper.recordPrivateApi('call.LiveObject.getObjectId'); const counterId = counter.getObjectId(); @@ -3201,47 +3192,62 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, { - description: 'RealtimeObjects.createCounter throws on invalid input', + description: 'RealtimeObject.createCounter throws on invalid input', action: async (ctx) => { - const { root, objects } = ctx; + const { root, realtimeObject } = ctx; - await expectToThrowAsync(async () => objects.createCounter(null), 'Counter value should be a valid number'); await expectToThrowAsync( - async () => objects.createCounter(Number.NaN), + async () => realtimeObject.createCounter(null), + 'Counter value should be a valid number', + ); + await expectToThrowAsync( + async () => realtimeObject.createCounter(Number.NaN), + 'Counter value should be a valid number', + ); + await expectToThrowAsync( + async () => realtimeObject.createCounter(Number.POSITIVE_INFINITY), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => objects.createCounter(Number.POSITIVE_INFINITY), + async () => realtimeObject.createCounter(Number.NEGATIVE_INFINITY), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => objects.createCounter(Number.NEGATIVE_INFINITY), + async () => realtimeObject.createCounter('foo'), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => objects.createCounter('foo'), + async () => realtimeObject.createCounter(BigInt(1)), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => objects.createCounter(BigInt(1)), + async () => realtimeObject.createCounter(true), 'Counter value should be a valid number', ); - await expectToThrowAsync(async () => objects.createCounter(true), 'Counter value should be a valid number'); await expectToThrowAsync( - async () => objects.createCounter(Symbol()), + async () => realtimeObject.createCounter(Symbol()), + 'Counter value should be a valid number', + ); + await expectToThrowAsync( + async () => realtimeObject.createCounter({}), + 'Counter value should be a valid number', + ); + await expectToThrowAsync( + async () => realtimeObject.createCounter([]), + 'Counter value should be a valid number', + ); + await expectToThrowAsync( + async () => realtimeObject.createCounter(root), 'Counter value should be a valid number', ); - await expectToThrowAsync(async () => objects.createCounter({}), 'Counter value should be a valid number'); - await expectToThrowAsync(async () => objects.createCounter([]), 'Counter value should be a valid number'); - await expectToThrowAsync(async () => objects.createCounter(root), 'Counter value should be a valid number'); }, }, { allTransportsAndProtocols: true, - description: 'RealtimeObjects.createMap sends MAP_CREATE operation with primitive values', + description: 'RealtimeObject.createMap sends MAP_CREATE operation with primitive values', action: async (ctx) => { - const { objects, helper } = ctx; + const { realtimeObject, helper } = ctx; const maps = await Promise.all( primitiveMapsFixtures.map(async (mapFixture) => { @@ -3262,7 +3268,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, {}) : undefined; - return objects.createMap(entries); + return realtimeObject.createMap(entries); }), ); @@ -3293,9 +3299,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { allTransportsAndProtocols: true, - description: 'RealtimeObjects.createMap sends MAP_CREATE operation with reference to another LiveObject', + description: 'RealtimeObject.createMap sends MAP_CREATE operation with reference to another LiveObject', action: async (ctx) => { - const { root, objectsHelper, channelName, objects } = ctx; + const { root, objectsHelper, channelName, realtimeObject } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'counter'), @@ -3316,7 +3322,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const counter = root.get('counter'); const map = root.get('map'); - const newMap = await objects.createMap({ counter, map }); + const newMap = await realtimeObject.createMap({ counter, map }); expect(newMap, 'Check map exists').to.exist; expectInstanceOf(newMap, 'LiveMap', 'Check map instance is of an expected class'); @@ -3334,13 +3340,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { allTransportsAndProtocols: true, - description: 'LiveMap created with RealtimeObjects.createMap can be assigned to the object tree', + description: 'LiveMap created with RealtimeObject.createMap can be assigned to the object tree', action: async (ctx) => { - const { root, objects } = ctx; + const { root, realtimeObject } = ctx; const mapCreatedPromise = waitForMapKeyUpdate(root, 'map'); - const counter = await objects.createCounter(); - const map = await objects.createMap({ foo: 'bar', baz: counter }); + const counter = await realtimeObject.createCounter(); + const map = await realtimeObject.createMap({ foo: 'bar', baz: counter }); await root.set('map', map); await mapCreatedPromise; @@ -3364,29 +3370,29 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: - 'RealtimeObjects.createMap can return LiveMap with initial value without applying CREATE operation', + 'RealtimeObject.createMap can return LiveMap with initial value without applying CREATE operation', action: async (ctx) => { - const { objects, helper } = ctx; + const { realtimeObject, helper } = ctx; // prevent publishing of ops to realtime so we guarantee that the initial value doesn't come from a CREATE op - helper.recordPrivateApi('replace.RealtimeObjects.publish'); - objects.publish = () => {}; + helper.recordPrivateApi('replace.RealtimeObject.publish'); + realtimeObject.publish = () => {}; - const map = await objects.createMap({ foo: 'bar' }); + const map = await realtimeObject.createMap({ foo: 'bar' }); expect(map.get('foo')).to.equal('bar', `Check map has expected initial value`); }, }, { allTransportsAndProtocols: true, - description: 'RealtimeObjects.createMap can return LiveMap with initial value from applied CREATE operation', + description: 'RealtimeObject.createMap can return LiveMap with initial value from applied CREATE operation', action: async (ctx) => { - const { objects, objectsHelper, helper, channel } = ctx; + const { realtimeObject, objectsHelper, helper, channel } = ctx; // instead of sending CREATE op to the realtime, echo it immediately to the client // with forged initial value so we can check that map gets initialized with a value from a CREATE op - helper.recordPrivateApi('replace.RealtimeObjects.publish'); - objects.publish = async (objectMessages) => { + helper.recordPrivateApi('replace.RealtimeObject.publish'); + realtimeObject.publish = async (objectMessages) => { const mapId = objectMessages[0].operation.objectId; // this should result execute regular operation application procedure and create an object in the pool with forged initial value await objectsHelper.processObjectOperationMessageOnChannel({ @@ -3402,7 +3408,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); }; - const map = await objects.createMap({ foo: 'bar' }); + const map = await realtimeObject.createMap({ foo: 'bar' }); // map should be created with forged initial value instead of the actual one expect(map.get('foo'), `Check key "foo" was not set on a map client-side`).to.not.exist; @@ -3415,16 +3421,16 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: - 'initial value is not double counted for LiveMap from RealtimeObjects.createMap when CREATE op is received', + 'initial value is not double counted for LiveMap from RealtimeObject.createMap when CREATE op is received', action: async (ctx) => { - const { objects, objectsHelper, helper, channel } = ctx; + const { realtimeObject, objectsHelper, helper, channel } = ctx; // prevent publishing of ops to realtime so we can guarantee order of operations - helper.recordPrivateApi('replace.RealtimeObjects.publish'); - objects.publish = () => {}; + helper.recordPrivateApi('replace.RealtimeObject.publish'); + realtimeObject.publish = () => {}; // create map locally, should have an initial value set - const map = await objects.createMap({ foo: 'bar' }); + const map = await realtimeObject.createMap({ foo: 'bar' }); helper.recordPrivateApi('call.LiveObject.getObjectId'); const mapId = map.getObjectId(); @@ -3454,50 +3460,62 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, { - description: 'RealtimeObjects.createMap throws on invalid input', + description: 'RealtimeObject.createMap throws on invalid input', action: async (ctx) => { - const { root, objects } = ctx; + const { root, realtimeObject } = ctx; - await expectToThrowAsync(async () => objects.createMap(null), 'Map entries should be a key-value object'); - await expectToThrowAsync(async () => objects.createMap('foo'), 'Map entries should be a key-value object'); - await expectToThrowAsync(async () => objects.createMap(1), 'Map entries should be a key-value object'); await expectToThrowAsync( - async () => objects.createMap(BigInt(1)), + async () => realtimeObject.createMap(null), + 'Map entries should be a key-value object', + ); + await expectToThrowAsync( + async () => realtimeObject.createMap('foo'), + 'Map entries should be a key-value object', + ); + await expectToThrowAsync( + async () => realtimeObject.createMap(1), 'Map entries should be a key-value object', ); - await expectToThrowAsync(async () => objects.createMap(true), 'Map entries should be a key-value object'); await expectToThrowAsync( - async () => objects.createMap(Symbol()), + async () => realtimeObject.createMap(BigInt(1)), + 'Map entries should be a key-value object', + ); + await expectToThrowAsync( + async () => realtimeObject.createMap(true), + 'Map entries should be a key-value object', + ); + await expectToThrowAsync( + async () => realtimeObject.createMap(Symbol()), 'Map entries should be a key-value object', ); await expectToThrowAsync( - async () => objects.createMap({ key: undefined }), + async () => realtimeObject.createMap({ key: undefined }), 'Map value data type is unsupported', ); await expectToThrowAsync( - async () => objects.createMap({ key: null }), + async () => realtimeObject.createMap({ key: null }), 'Map value data type is unsupported', ); await expectToThrowAsync( - async () => objects.createMap({ key: BigInt(1) }), + async () => realtimeObject.createMap({ key: BigInt(1) }), 'Map value data type is unsupported', ); await expectToThrowAsync( - async () => objects.createMap({ key: Symbol() }), + async () => realtimeObject.createMap({ key: Symbol() }), 'Map value data type is unsupported', ); }, }, { - description: 'batch API getRoot method is synchronous', + description: 'batch API get method is synchronous', action: async (ctx) => { - const { objects } = ctx; + const { realtimeObject } = ctx; - await objects.batch((ctx) => { - const root = ctx.getRoot(); - expect(root, 'Check getRoot method in a BatchContext returns root object synchronously').to.exist; + await realtimeObject.batch((ctx) => { + const root = ctx.get(); + expect(root, 'Check BatchContext.get() returns object synchronously').to.exist; expectInstanceOf(root, 'LiveMap', 'root object obtained from a BatchContext is a LiveMap'); }); }, @@ -3506,20 +3524,20 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'batch API .get method on a map returns BatchContext* wrappers for objects', action: async (ctx) => { - const { root, objects } = ctx; + const { root, realtimeObject } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'map'), ]); - const counter = await objects.createCounter(1); - const map = await objects.createMap({ innerCounter: counter }); + const counter = await realtimeObject.createCounter(1); + const map = await realtimeObject.createMap({ innerCounter: counter }); await root.set('counter', counter); await root.set('map', map); await objectsCreatedPromise; - await objects.batch((ctx) => { - const ctxRoot = ctx.getRoot(); + await realtimeObject.batch((ctx) => { + const ctxRoot = ctx.get(); const ctxCounter = ctxRoot.get('counter'); const ctxMap = ctxRoot.get('map'); const ctxInnerCounter = ctxMap.get('innerCounter'); @@ -3549,20 +3567,20 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'batch API access API methods on objects work and are synchronous', action: async (ctx) => { - const { root, objects } = ctx; + const { root, realtimeObject } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'map'), ]); - const counter = await objects.createCounter(1); - const map = await objects.createMap({ foo: 'bar' }); + const counter = await realtimeObject.createCounter(1); + const map = await realtimeObject.createMap({ foo: 'bar' }); await root.set('counter', counter); await root.set('map', map); await objectsCreatedPromise; - await objects.batch((ctx) => { - const ctxRoot = ctx.getRoot(); + await realtimeObject.batch((ctx) => { + const ctxRoot = ctx.get(); const ctxCounter = ctxRoot.get('counter'); const ctxMap = ctxRoot.get('map'); @@ -3591,20 +3609,20 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'batch API write API methods on objects do not mutate objects inside the batch callback', action: async (ctx) => { - const { root, objects } = ctx; + const { root, realtimeObject } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'map'), ]); - const counter = await objects.createCounter(1); - const map = await objects.createMap({ foo: 'bar' }); + const counter = await realtimeObject.createCounter(1); + const map = await realtimeObject.createMap({ foo: 'bar' }); await root.set('counter', counter); await root.set('map', map); await objectsCreatedPromise; - await objects.batch((ctx) => { - const ctxRoot = ctx.getRoot(); + await realtimeObject.batch((ctx) => { + const ctxRoot = ctx.get(); const ctxCounter = ctxRoot.get('counter'); const ctxMap = ctxRoot.get('map'); @@ -3639,20 +3657,20 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'batch API scheduled operations are applied when batch callback is finished', action: async (ctx) => { - const { root, objects } = ctx; + const { root, realtimeObject } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'map'), ]); - const counter = await objects.createCounter(1); - const map = await objects.createMap({ foo: 'bar' }); + const counter = await realtimeObject.createCounter(1); + const map = await realtimeObject.createMap({ foo: 'bar' }); await root.set('counter', counter); await root.set('map', map); await objectsCreatedPromise; - await objects.batch((ctx) => { - const ctxRoot = ctx.getRoot(); + await realtimeObject.batch((ctx) => { + const ctxRoot = ctx.get(); const ctxCounter = ctxRoot.get('counter'); const ctxMap = ctxRoot.get('map'); @@ -3672,11 +3690,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'batch API can be called without scheduling any operations', action: async (ctx) => { - const { objects } = ctx; + const { realtimeObject } = ctx; let caughtError; try { - await objects.batch((ctx) => {}); + await realtimeObject.batch((ctx) => {}); } catch (error) { caughtError = error; } @@ -3690,14 +3708,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'batch API scheduled operations can be canceled by throwing an error in the batch callback', action: async (ctx) => { - const { root, objects } = ctx; + const { root, realtimeObject } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'map'), ]); - const counter = await objects.createCounter(1); - const map = await objects.createMap({ foo: 'bar' }); + const counter = await realtimeObject.createCounter(1); + const map = await realtimeObject.createMap({ foo: 'bar' }); await root.set('counter', counter); await root.set('map', map); await objectsCreatedPromise; @@ -3705,8 +3723,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const cancelError = new Error('cancel batch'); let caughtError; try { - await objects.batch((ctx) => { - const ctxRoot = ctx.getRoot(); + await realtimeObject.batch((ctx) => { + const ctxRoot = ctx.get(); const ctxCounter = ctxRoot.get('counter'); const ctxMap = ctxRoot.get('map'); @@ -3735,14 +3753,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: `batch API batch context and derived objects can't be interacted with after the batch call`, action: async (ctx) => { - const { root, objects } = ctx; + const { root, realtimeObject } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'map'), ]); - const counter = await objects.createCounter(1); - const map = await objects.createMap({ foo: 'bar' }); + const counter = await realtimeObject.createCounter(1); + const map = await realtimeObject.createMap({ foo: 'bar' }); await root.set('counter', counter); await root.set('map', map); await objectsCreatedPromise; @@ -3751,8 +3769,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function let savedCtxCounter; let savedCtxMap; - await objects.batch((ctx) => { - const ctxRoot = ctx.getRoot(); + await realtimeObject.batch((ctx) => { + const ctxRoot = ctx.get(); savedCtx = ctx; savedCtxCounter = ctxRoot.get('counter'); savedCtxMap = ctxRoot.get('map'); @@ -3776,14 +3794,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: `batch API batch context and derived objects can't be interacted with after error was thrown from batch callback`, action: async (ctx) => { - const { root, objects } = ctx; + const { root, realtimeObject } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'map'), ]); - const counter = await objects.createCounter(1); - const map = await objects.createMap({ foo: 'bar' }); + const counter = await realtimeObject.createCounter(1); + const map = await realtimeObject.createMap({ foo: 'bar' }); await root.set('counter', counter); await root.set('map', map); await objectsCreatedPromise; @@ -3794,8 +3812,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function let caughtError; try { - await objects.batch((ctx) => { - const ctxRoot = ctx.getRoot(); + await realtimeObject.batch((ctx) => { + const ctxRoot = ctx.get(); savedCtx = ctx; savedCtxCounter = ctxRoot.get('counter'); savedCtxMap = ctxRoot.get('map'); @@ -3885,7 +3903,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: `BatchContextLiveMap enumeration`, action: async (ctx) => { - const { root, objectsHelper, channel, objects } = ctx; + const { root, objectsHelper, channel, realtimeObject } = ctx; const counterId1 = objectsHelper.fakeCounterObjectId(); const counterId2 = objectsHelper.fakeCounterObjectId(); @@ -3924,8 +3942,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const counter1 = await root.get('counter1'); - await objects.batch(async (ctx) => { - const ctxRoot = ctx.getRoot(); + await realtimeObject.batch(async (ctx) => { + const ctxRoot = ctx.get(); // enumeration methods should not count tombstoned entries expect(ctxRoot.size()).to.equal(2, 'Check BatchContextLiveMap.size() returns expected number of keys'); @@ -3965,13 +3983,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await helper.monitorConnectionThenCloseAndFinishAsync(async () => { const channel = client.channels.get(channelName, channelOptionsWithObjects()); - const objects = channel.objects; + const realtimeObject = channel.object; await channel.attach(); - const root = await objects.getRoot(); + const root = await channel.object.get(); await scenario.action({ - objects, + realtimeObject, root, objectsHelper, channelName, @@ -4183,7 +4201,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await helper.monitorConnectionThenCloseAndFinishAsync(async () => { const publishChannel = publishClient.channels.get(channelName, channelOptionsWithObjects()); await publishChannel.attach(); - const publishRoot = await publishChannel.objects.getRoot(); + const publishRoot = await publishChannel.object.get(); // capture the connection ID once the client is connected publishConnectionId = publishClient.connection.id; @@ -4616,10 +4634,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await helper.monitorConnectionThenCloseAndFinishAsync(async () => { const channel = client.channels.get(channelName, channelOptionsWithObjects()); - const objects = channel.objects; await channel.attach(); - const root = await objects.getRoot(); + const root = await channel.object.get(); const sampleMapKey = 'sampleMap'; const sampleCounterKey = 'sampleCounter'; @@ -4663,18 +4680,18 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await client.connection.once('connected'); const channel = client.channels.get('channel', channelOptionsWithObjects()); - const objects = channel.objects; + const realtimeObject = channel.object; const connectionManager = client.connection.connectionManager; const connectionDetails = connectionManager.connectionDetails; // gcGracePeriod should be set after the initial connection - helper.recordPrivateApi('read.RealtimeObjects.gcGracePeriod'); + helper.recordPrivateApi('read.RealtimeObject.gcGracePeriod'); expect( - objects.gcGracePeriod, + realtimeObject.gcGracePeriod, 'Check gcGracePeriod is set after initial connection from connectionDetails.objectsGCGracePeriod', ).to.exist; - helper.recordPrivateApi('read.RealtimeObjects.gcGracePeriod'); - expect(objects.gcGracePeriod).to.equal( + helper.recordPrivateApi('read.RealtimeObject.gcGracePeriod'); + expect(realtimeObject.gcGracePeriod).to.equal( connectionDetails.objectsGCGracePeriod, 'Check gcGracePeriod is set to equal connectionDetails.objectsGCGracePeriod', ); @@ -4699,8 +4716,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // wait for next tick to ensure the connectionDetails event was processed by Objects plugin await new Promise((res) => nextTick(res)); - helper.recordPrivateApi('read.RealtimeObjects.gcGracePeriod'); - expect(objects.gcGracePeriod).to.equal(999, 'Check gcGracePeriod is updated on new CONNECTED event'); + helper.recordPrivateApi('read.RealtimeObject.gcGracePeriod'); + expect(realtimeObject.gcGracePeriod).to.equal(999, 'Check gcGracePeriod is updated on new CONNECTED event'); }, client); }); @@ -4712,14 +4729,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await client.connection.once('connected'); const channel = client.channels.get('channel', channelOptionsWithObjects()); - const objects = channel.objects; + const realtimeObject = channel.object; const connectionManager = client.connection.connectionManager; const connectionDetails = connectionManager.connectionDetails; - helper.recordPrivateApi('read.RealtimeObjects._DEFAULTS.gcGracePeriod'); - helper.recordPrivateApi('write.RealtimeObjects.gcGracePeriod'); + helper.recordPrivateApi('read.RealtimeObject._DEFAULTS.gcGracePeriod'); + helper.recordPrivateApi('write.RealtimeObject.gcGracePeriod'); // set gcGracePeriod to a value different from the default - objects.gcGracePeriod = ObjectsPlugin.RealtimeObjects._DEFAULTS.gcGracePeriod + 1; + realtimeObject.gcGracePeriod = ObjectsPlugin.RealtimeObject._DEFAULTS.gcGracePeriod + 1; const connectionDetailsPromise = connectionManager.once('connectiondetails'); @@ -4739,10 +4756,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // wait for next tick to ensure the connectionDetails event was processed by Objects plugin await new Promise((res) => nextTick(res)); - helper.recordPrivateApi('read.RealtimeObjects._DEFAULTS.gcGracePeriod'); - helper.recordPrivateApi('read.RealtimeObjects.gcGracePeriod'); - expect(objects.gcGracePeriod).to.equal( - ObjectsPlugin.RealtimeObjects._DEFAULTS.gcGracePeriod, + helper.recordPrivateApi('read.RealtimeObject._DEFAULTS.gcGracePeriod'); + helper.recordPrivateApi('read.RealtimeObject.gcGracePeriod'); + expect(realtimeObject.gcGracePeriod).to.equal( + ObjectsPlugin.RealtimeObject._DEFAULTS.gcGracePeriod, 'Check gcGracePeriod is set to a default value if connectionDetails.objectsGCGracePeriod is missing', ); }, client); @@ -4754,7 +4771,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'tombstoned object is removed from the pool after the GC grace period', action: async (ctx) => { - const { objectsHelper, channelName, channel, objects, helper, waitForGCCycles, client } = ctx; + const { objectsHelper, channelName, channel, realtimeObject, helper, waitForGCCycles, client } = ctx; const counterCreatedPromise = waitForObjectOperation(helper, client, ObjectsHelper.ACTIONS.COUNTER_CREATE); // send a CREATE op, this adds an object to the pool @@ -4764,8 +4781,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ); await counterCreatedPromise; - helper.recordPrivateApi('call.RealtimeObjects._objectsPool.get'); - expect(objects._objectsPool.get(objectId), 'Check object exists in the pool after creation').to.exist; + helper.recordPrivateApi('call.RealtimeObject._objectsPool.get'); + expect(realtimeObject._objectsPool.get(objectId), 'Check object exists in the pool after creation').to + .exist; // inject OBJECT_DELETE for the object. this should tombstone the object and make it inaccessible to the end user, but still keep it in memory in the local pool await objectsHelper.processObjectOperationMessageOnChannel({ @@ -4775,14 +4793,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function state: [objectsHelper.objectDeleteOp({ objectId })], }); - helper.recordPrivateApi('call.RealtimeObjects._objectsPool.get'); + helper.recordPrivateApi('call.RealtimeObject._objectsPool.get'); expect( - objects._objectsPool.get(objectId), + realtimeObject._objectsPool.get(objectId), 'Check object exists in the pool immediately after OBJECT_DELETE', ).to.exist; - helper.recordPrivateApi('call.RealtimeObjects._objectsPool.get'); + helper.recordPrivateApi('call.RealtimeObject._objectsPool.get'); helper.recordPrivateApi('call.LiveObject.isTombstoned'); - expect(objects._objectsPool.get(objectId).isTombstoned()).to.equal( + expect(realtimeObject._objectsPool.get(objectId).isTombstoned()).to.equal( true, `Check object's "tombstone" flag is set to "true" after OBJECT_DELETE`, ); @@ -4791,9 +4809,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await waitForGCCycles(2); // object should be removed from the local pool entirely now, as the GC grace period has passed - helper.recordPrivateApi('call.RealtimeObjects._objectsPool.get'); + helper.recordPrivateApi('call.RealtimeObject._objectsPool.get'); expect( - objects._objectsPool.get(objectId), + realtimeObject._objectsPool.get(objectId), 'Check object exists does not exist in the pool after the GC grace period expiration', ).to.not.exist; }, @@ -4852,39 +4870,39 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function /** @nospec */ forScenarios(this, tombstonesGCScenarios, async function (helper, scenario, clientOptions, channelName) { try { - helper.recordPrivateApi('write.RealtimeObjects._DEFAULTS.gcInterval'); - ObjectsPlugin.RealtimeObjects._DEFAULTS.gcInterval = 500; + helper.recordPrivateApi('write.RealtimeObject._DEFAULTS.gcInterval'); + ObjectsPlugin.RealtimeObject._DEFAULTS.gcInterval = 500; const objectsHelper = new ObjectsHelper(helper); const client = RealtimeWithObjects(helper, clientOptions); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { const channel = client.channels.get(channelName, channelOptionsWithObjects()); - const objects = channel.objects; + const realtimeObject = channel.object; await channel.attach(); - const root = await objects.getRoot(); + const root = await channel.object.get(); - helper.recordPrivateApi('read.RealtimeObjects.gcGracePeriod'); - const gcGracePeriodOriginal = objects.gcGracePeriod; - helper.recordPrivateApi('write.RealtimeObjects.gcGracePeriod'); - objects.gcGracePeriod = 250; + helper.recordPrivateApi('read.RealtimeObject.gcGracePeriod'); + const gcGracePeriodOriginal = realtimeObject.gcGracePeriod; + helper.recordPrivateApi('write.RealtimeObject.gcGracePeriod'); + realtimeObject.gcGracePeriod = 250; // helper function to spy on the GC interval callback and wait for a specific number of GC cycles. // returns a promise which will resolve when required number of cycles have happened. const waitForGCCycles = (cycles) => { - const onGCIntervalOriginal = objects._objectsPool._onGCInterval; + const onGCIntervalOriginal = realtimeObject._objectsPool._onGCInterval; let gcCalledTimes = 0; return new Promise((resolve) => { - helper.recordPrivateApi('replace.RealtimeObjects._objectsPool._onGCInterval'); - objects._objectsPool._onGCInterval = function () { - helper.recordPrivateApi('call.RealtimeObjects._objectsPool._onGCInterval'); + helper.recordPrivateApi('replace.RealtimeObject._objectsPool._onGCInterval'); + realtimeObject._objectsPool._onGCInterval = function () { + helper.recordPrivateApi('call.RealtimeObject._objectsPool._onGCInterval'); onGCIntervalOriginal.call(this); gcCalledTimes++; if (gcCalledTimes >= cycles) { resolve(); - objects._objectsPool._onGCInterval = onGCIntervalOriginal; + realtimeObject._objectsPool._onGCInterval = onGCIntervalOriginal; } }; }); @@ -4896,22 +4914,22 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function objectsHelper, channelName, channel, - objects, + realtimeObject, helper, waitForGCCycles, }); - helper.recordPrivateApi('write.RealtimeObjects.gcGracePeriod'); - objects.gcGracePeriod = gcGracePeriodOriginal; + helper.recordPrivateApi('write.RealtimeObject.gcGracePeriod'); + realtimeObject.gcGracePeriod = gcGracePeriodOriginal; }, client); } finally { - helper.recordPrivateApi('write.RealtimeObjects._DEFAULTS.gcInterval'); - ObjectsPlugin.RealtimeObjects._DEFAULTS.gcInterval = gcIntervalOriginal; + helper.recordPrivateApi('write.RealtimeObject._DEFAULTS.gcInterval'); + ObjectsPlugin.RealtimeObject._DEFAULTS.gcInterval = gcIntervalOriginal; } }); - const expectAccessApiToThrow = async ({ objects, map, counter, errorMsg }) => { - await expectToThrowAsync(async () => objects.getRoot(), errorMsg); + const expectAccessApiToThrow = async ({ realtimeObject, map, counter, errorMsg }) => { + await expectToThrowAsync(async () => realtimeObject.get(), errorMsg); expect(() => counter.value()).to.throw(errorMsg); @@ -4928,10 +4946,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function } }; - const expectWriteApiToThrow = async ({ objects, map, counter, errorMsg }) => { - await expectToThrowAsync(async () => objects.batch(), errorMsg); - await expectToThrowAsync(async () => objects.createMap(), errorMsg); - await expectToThrowAsync(async () => objects.createCounter(), errorMsg); + const expectWriteApiToThrow = async ({ realtimeObject, map, counter, errorMsg }) => { + await expectToThrowAsync(async () => realtimeObject.batch(), errorMsg); + await expectToThrowAsync(async () => realtimeObject.createMap(), errorMsg); + await expectToThrowAsync(async () => realtimeObject.createCounter(), errorMsg); await expectToThrowAsync(async () => counter.increment(), errorMsg); await expectToThrowAsync(async () => counter.decrement(), errorMsg); @@ -4947,7 +4965,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function /** Make sure to call this inside the batch method as batch objects can't be interacted with outside the batch callback */ const expectAccessBatchApiToThrow = ({ ctx, map, counter, errorMsg }) => { - expect(() => ctx.getRoot()).to.throw(errorMsg); + expect(() => ctx.get()).to.throw(errorMsg); expect(() => counter.value()).to.throw(errorMsg); @@ -4971,12 +4989,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'public API throws missing object modes error when attached without correct modes', action: async (ctx) => { - const { objects, channel, map, counter } = ctx; + const { realtimeObject, channel, map, counter } = ctx; // obtain batch context with valid modes first - await objects.batch((ctx) => { - const map = ctx.getRoot().get('map'); - const counter = ctx.getRoot().get('counter'); + await realtimeObject.batch((ctx) => { + const map = ctx.get().get('map'); + const counter = ctx.get().get('counter'); // now simulate missing modes channel.modes = []; @@ -4984,8 +5002,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expectWriteBatchApiToThrow({ ctx, map, counter, errorMsg: '"object_publish" channel mode' }); }); - await expectAccessApiToThrow({ objects, map, counter, errorMsg: '"object_subscribe" channel mode' }); - await expectWriteApiToThrow({ objects, map, counter, errorMsg: '"object_publish" channel mode' }); + await expectAccessApiToThrow({ realtimeObject, map, counter, errorMsg: '"object_subscribe" channel mode' }); + await expectWriteApiToThrow({ realtimeObject, map, counter, errorMsg: '"object_publish" channel mode' }); }, }, @@ -4993,12 +5011,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'public API throws missing object modes error when not yet attached but client options are missing correct modes', action: async (ctx) => { - const { objects, channel, map, counter, helper } = ctx; + const { realtimeObject, channel, map, counter, helper } = ctx; // obtain batch context with valid modes first - await objects.batch((ctx) => { - const map = ctx.getRoot().get('map'); - const counter = ctx.getRoot().get('counter'); + await realtimeObject.batch((ctx) => { + const map = ctx.get().get('map'); + const counter = ctx.get().get('counter'); // now simulate a situation where we're not yet attached/modes are not received on ATTACHED event channel.modes = undefined; helper.recordPrivateApi('write.channel.channelOptions.modes'); @@ -5008,20 +5026,20 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expectWriteBatchApiToThrow({ ctx, map, counter, errorMsg: '"object_publish" channel mode' }); }); - await expectAccessApiToThrow({ objects, map, counter, errorMsg: '"object_subscribe" channel mode' }); - await expectWriteApiToThrow({ objects, map, counter, errorMsg: '"object_publish" channel mode' }); + await expectAccessApiToThrow({ realtimeObject, map, counter, errorMsg: '"object_subscribe" channel mode' }); + await expectWriteApiToThrow({ realtimeObject, map, counter, errorMsg: '"object_publish" channel mode' }); }, }, { description: 'public API throws invalid channel state error when channel DETACHED', action: async (ctx) => { - const { objects, channel, map, counter, helper } = ctx; + const { realtimeObject, channel, map, counter, helper } = ctx; // obtain batch context with valid channel state first - await objects.batch((ctx) => { - const map = ctx.getRoot().get('map'); - const counter = ctx.getRoot().get('counter'); + await realtimeObject.batch((ctx) => { + const map = ctx.get().get('map'); + const counter = ctx.get().get('counter'); // now simulate channel state change helper.recordPrivateApi('call.channel.requestState'); channel.requestState('detached'); @@ -5031,24 +5049,29 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await expectAccessApiToThrow({ - objects, + realtimeObject, + map, + counter, + errorMsg: 'failed as channel state is detached', + }); + await expectWriteApiToThrow({ + realtimeObject, map, counter, errorMsg: 'failed as channel state is detached', }); - await expectWriteApiToThrow({ objects, map, counter, errorMsg: 'failed as channel state is detached' }); }, }, { description: 'public API throws invalid channel state error when channel FAILED', action: async (ctx) => { - const { objects, channel, map, counter, helper } = ctx; + const { realtimeObject, channel, map, counter, helper } = ctx; // obtain batch context with valid channel state first - await objects.batch((ctx) => { - const map = ctx.getRoot().get('map'); - const counter = ctx.getRoot().get('counter'); + await realtimeObject.batch((ctx) => { + const map = ctx.get().get('map'); + const counter = ctx.get().get('counter'); // now simulate channel state change helper.recordPrivateApi('call.channel.requestState'); channel.requestState('failed'); @@ -5058,24 +5081,29 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await expectAccessApiToThrow({ - objects, + realtimeObject, + map, + counter, + errorMsg: 'failed as channel state is failed', + }); + await expectWriteApiToThrow({ + realtimeObject, map, counter, errorMsg: 'failed as channel state is failed', }); - await expectWriteApiToThrow({ objects, map, counter, errorMsg: 'failed as channel state is failed' }); }, }, { description: 'public write API throws invalid channel state error when channel SUSPENDED', action: async (ctx) => { - const { objects, channel, map, counter, helper } = ctx; + const { realtimeObject, channel, map, counter, helper } = ctx; // obtain batch context with valid channel state first - await objects.batch((ctx) => { - const map = ctx.getRoot().get('map'); - const counter = ctx.getRoot().get('counter'); + await realtimeObject.batch((ctx) => { + const map = ctx.get().get('map'); + const counter = ctx.get().get('counter'); // now simulate channel state change helper.recordPrivateApi('call.channel.requestState'); channel.requestState('suspended'); @@ -5084,7 +5112,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await expectWriteApiToThrow({ - objects, + realtimeObject, map, counter, errorMsg: 'failed as channel state is suspended', @@ -5095,12 +5123,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'public write API throws invalid channel option when "echoMessages" is disabled', action: async (ctx) => { - const { objects, client, map, counter, helper } = ctx; + const { realtimeObject, client, map, counter, helper } = ctx; // obtain batch context with valid client options first - await objects.batch((ctx) => { - const map = ctx.getRoot().get('map'); - const counter = ctx.getRoot().get('counter'); + await realtimeObject.batch((ctx) => { + const map = ctx.get().get('map'); + const counter = ctx.get().get('counter'); // now simulate echoMessages was disabled helper.recordPrivateApi('write.realtime.options.echoMessages'); client.options.echoMessages = false; @@ -5108,7 +5136,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expectWriteBatchApiToThrow({ ctx, map, counter, errorMsg: '"echoMessages" client option' }); }); - await expectWriteApiToThrow({ objects, map, counter, errorMsg: '"echoMessages" client option' }); + await expectWriteApiToThrow({ realtimeObject, map, counter, errorMsg: '"echoMessages" client option' }); }, }, ]; @@ -5122,22 +5150,32 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // attach with correct channel modes so we can create Objects on the root for testing. // some scenarios will modify the underlying modes array to test specific behavior const channel = client.channels.get(channelName, channelOptionsWithObjects()); - const objects = channel.objects; + const realtimeObject = channel.object; await channel.attach(); - const root = await objects.getRoot(); + const root = await channel.object.get(); const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'map'), waitForMapKeyUpdate(root, 'counter'), ]); - const map = await objects.createMap(); - const counter = await objects.createCounter(); + const map = await realtimeObject.createMap(); + const counter = await realtimeObject.createCounter(); await root.set('map', map); await root.set('counter', counter); await objectsCreatedPromise; - await scenario.action({ objects, objectsHelper, channelName, channel, root, map, counter, helper, client }); + await scenario.action({ + realtimeObject, + objectsHelper, + channelName, + channel, + root, + map, + counter, + helper, + client, + }); }, client); }); @@ -5174,10 +5212,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await connectionDetailsPromise; const channel = client.channels.get('channel', channelOptionsWithObjects()); - const objects = channel.objects; await channel.attach(); - const root = await objects.getRoot(); + const root = await channel.object.get(); const data = new Array(100).fill('a').join(''); const error = await expectToThrowAsync( From 13819bf6d97db3808b10d308d84e48d56990fdf3 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Wed, 24 Sep 2025 05:12:41 +0100 Subject: [PATCH 03/45] Update Objects docstrings to not use "root" terminology --- ably.d.ts | 36 +++++++++--------- src/plugins/objects/batchcontext.ts | 2 +- src/plugins/objects/realtimeobject.ts | 2 +- .../browser/template/src/index-objects.ts | 38 +++++++++---------- 4 files changed, 39 insertions(+), 39 deletions(-) diff --git a/ably.d.ts b/ably.d.ts index 46ce7c20d7..58fb46019a 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2288,24 +2288,24 @@ export type LiveObjectLifecycleEvent = LiveObjectLifecycleEvents.DELETED; */ export declare interface RealtimeObject { /** - * Retrieves the root {@link LiveMap} object for Objects on a channel. + * Retrieves the {@link LiveMap} object - the entrypoint for Objects on a channel. * * A type parameter can be provided to describe the structure of the Objects on the channel. By default, it uses types from the globally defined `AblyObjectsTypes` interface. * - * You can specify custom types for Objects by defining a global `AblyObjectsTypes` interface with a `root` property that conforms to {@link LiveMapType}. + * You can specify custom types for Objects by defining a global `AblyObjectsTypes` interface with a `object` property that conforms to {@link LiveMapType}. * * Example: * * ```typescript * import { LiveCounter } from 'ably'; * - * type MyRoot = { + * type MyObject = { * myTypedKey: LiveCounter; * }; * * declare global { * export interface AblyObjectsTypes { - * root: MyRoot; + * object: MyObject; * } * } * ``` @@ -2313,7 +2313,7 @@ export declare interface RealtimeObject { * @returns A promise which, upon success, will be fulfilled with a {@link LiveMap} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. * @experimental */ - get(): Promise>; + get(): Promise>; /** * Creates a new {@link LiveMap} object instance with the provided entries. @@ -2392,20 +2392,20 @@ declare global { export type LiveMapType = { [key: string]: PrimitiveObjectValue | LiveMap | LiveCounter | undefined }; /** - * The default type for the `root` object for Objects on a channel, based on the globally defined {@link AblyObjectsTypes} interface. + * The default type for the entrypoint {@link LiveMap} object on a channel, based on the globally defined {@link AblyObjectsTypes} interface. * - * - If no custom types are provided in `AblyObjectsTypes`, defaults to an untyped root map representation using the {@link LiveMapType} interface. - * - If a `root` type exists in `AblyObjectsTypes` and conforms to the {@link LiveMapType} interface, it is used as the type for the `root` object. - * - If the provided `root` type does not match {@link LiveMapType}, a type error message is returned. + * - If no custom types are provided in `AblyObjectsTypes`, defaults to an untyped map representation using the {@link LiveMapType} interface. + * - If an `object` key exists in `AblyObjectsTypes` and its type conforms to the {@link LiveMapType} interface, it is used as the type for the entrypoint {@link LiveMap} object. + * - If the provided type in `object` key does not match {@link LiveMapType}, a type error message is returned. */ -export type DefaultRoot = +export type AblyDefaultObject = // we need a way to know when no types were provided by the user. - // we expect a "root" property to be set on AblyObjectsTypes interface, e.g. it won't be "unknown" anymore - unknown extends AblyObjectsTypes['root'] - ? LiveMapType // no custom types provided; use the default untyped map representation for the root - : AblyObjectsTypes['root'] extends LiveMapType - ? AblyObjectsTypes['root'] // "root" property exists, and it is of an expected type, we can use this interface for the root object in Objects. - : `Provided type definition for the "root" object in AblyObjectsTypes is not of an expected LiveMapType`; + // we expect an "object" property to be set on AblyObjectsTypes interface, e.g. it won't be "unknown" anymore + unknown extends AblyObjectsTypes['object'] + ? LiveMapType // no custom types provided; use the default untyped map representation for the entrypoint map + : AblyObjectsTypes['object'] extends LiveMapType + ? AblyObjectsTypes['object'] // "object" property exists, and it is of an expected type, we can use this interface for the entrypoint map + : `Provided type definition for the channel \`object\` in AblyObjectsTypes is not of an expected LiveMapType`; /** * Object returned from an `on` call, allowing the listener provided in that call to be deregistered. @@ -2424,12 +2424,12 @@ export declare interface OnObjectsEventResponse { */ export declare interface BatchContext { /** - * Mirrors the {@link RealtimeObject.get} method and returns a {@link BatchContextLiveMap} wrapper for the root object on a channel. + * Mirrors the {@link RealtimeObject.get} method and returns a {@link BatchContextLiveMap} wrapper for the entrypoint {@link LiveMap} object on a channel. * * @returns A {@link BatchContextLiveMap} object. * @experimental */ - get(): BatchContextLiveMap; + get(): BatchContextLiveMap; } /** diff --git a/src/plugins/objects/batchcontext.ts b/src/plugins/objects/batchcontext.ts index f416daf9f7..4bc7014279 100644 --- a/src/plugins/objects/batchcontext.ts +++ b/src/plugins/objects/batchcontext.ts @@ -23,7 +23,7 @@ export class BatchContext { this._wrappedObjects.set(this._root.getObjectId(), new BatchContextLiveMap(this, this._realtimeObject, this._root)); } - get(): BatchContextLiveMap { + get(): BatchContextLiveMap { this._realtimeObject.throwIfInvalidAccessApiConfiguration(); this.throwIfClosed(); return this.getWrappedObject(ROOT_OBJECT_ID) as BatchContextLiveMap; diff --git a/src/plugins/objects/realtimeobject.ts b/src/plugins/objects/realtimeobject.ts index 62b7f67ba6..f6283295b6 100644 --- a/src/plugins/objects/realtimeobject.ts +++ b/src/plugins/objects/realtimeobject.ts @@ -79,7 +79,7 @@ export class RealtimeObject { * This is useful when working with multiple channels with different underlying data structure. * @spec RTO1 */ - async get(): Promise> { + async get(): Promise> { this.throwIfInvalidAccessApiConfiguration(); // RTO1a, RTO1b // if we're not synced yet, wait for sync sequence to finish before returning root diff --git a/test/package/browser/template/src/index-objects.ts b/test/package/browser/template/src/index-objects.ts index 7bfc5fb0a0..76d2905604 100644 --- a/test/package/browser/template/src/index-objects.ts +++ b/test/package/browser/template/src/index-objects.ts @@ -8,7 +8,7 @@ declare module globalThis { var testAblyPackage: () => Promise; } -type CustomRoot = { +type MyCustomObject = { numberKey: number; stringKey: string; booleanKey: boolean; @@ -24,11 +24,11 @@ type CustomRoot = { declare global { export interface AblyObjectsTypes { - root: CustomRoot; + object: MyCustomObject; } } -type ExplicitRootType = { +type ExplicitObjectType = { someOtherKey: string; }; @@ -40,32 +40,32 @@ globalThis.testAblyPackage = async function () { const channel = realtime.channels.get('channel', { modes: ['OBJECT_SUBSCRIBE', 'OBJECT_PUBLISH'] }); // check Objects can be accessed await channel.attach(); - // expect root to be a LiveMap instance with Objects types defined via the global AblyObjectsTypes interface + // expect entrypoint to be a LiveMap instance with Objects types defined via the global AblyObjectsTypes interface // also checks that we can refer to the Objects types exported from 'ably' by referencing a LiveMap interface - const root: Ably.LiveMap = await channel.object.get(); + const myObject: Ably.LiveMap = await channel.object.get(); - // check root has expected LiveMap TypeScript type methods - const size: number = root.size(); + // check entrypoint has expected LiveMap TypeScript type methods + const size: number = myObject.size(); // check custom user provided typings via AblyObjectsTypes are working: // any LiveMap.get() call can return undefined, as the LiveMap itself can be tombstoned (has empty state), // or referenced object is tombstoned. - // keys on a root: - const aNumber: number | undefined = root.get('numberKey'); - const aString: string | undefined = root.get('stringKey'); - const aBoolean: boolean | undefined = root.get('booleanKey'); - const userProvidedUndefined: string | undefined = root.get('couldBeUndefined'); - // objects on a root: - const counter: Ably.LiveCounter | undefined = root.get('counterKey'); - const map: AblyObjectsTypes['root']['mapKey'] | undefined = root.get('mapKey'); + // keys on the entrypoint: + const aNumber: number | undefined = myObject.get('numberKey'); + const aString: string | undefined = myObject.get('stringKey'); + const aBoolean: boolean | undefined = myObject.get('booleanKey'); + const userProvidedUndefined: string | undefined = myObject.get('couldBeUndefined'); + // objects on the entrypoint: + const counter: Ably.LiveCounter | undefined = myObject.get('counterKey'); + const map: AblyObjectsTypes['object']['mapKey'] | undefined = myObject.get('mapKey'); // check string literal types works - // need to use nullish coalescing as we didn't actually create any data on the root, + // need to use nullish coalescing as we didn't actually create any data on the entrypoint object, // so the next calls would fail. we only need to check that TypeScript types work const foo: 'bar' = map?.get('foo')!; const baz: 'qux' = map?.get('nestedMap')?.get('baz')!; // check LiveMap subscription callback has correct TypeScript types - const { unsubscribe } = root.subscribe(({ update }) => { + const { unsubscribe } = myObject.subscribe(({ update }) => { // check update object infers keys from map type const typedKeyOnMap = update.stringKey; switch (typedKeyOnMap) { @@ -89,6 +89,6 @@ globalThis.testAblyPackage = async function () { counterSubscribeResponse?.unsubscribe(); // check can provide custom types for the object.get() method, ignoring global AblyObjectsTypes interface - const explicitRoot: Ably.LiveMap = await channel.object.get(); - const someOtherKey: string | undefined = explicitRoot.get('someOtherKey'); + const explicitObjectType: Ably.LiveMap = await channel.object.get(); + const someOtherKey: string | undefined = explicitObjectType.get('someOtherKey'); }; From 1e8599584cd48d5ced6c36db38aa1d2c8eb4fcf7 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Wed, 17 Sep 2025 08:15:04 +0100 Subject: [PATCH 04/45] Add path based subscriptions support for LiveObjects Implements: - LiveObject parent tracking - Path-based LiveObject event emission - PathObject subscriptions path matching with deep subscriptions - Path event emission for LiveMap key updates - Full ObjectMessage (user-facing type) argument for subscription callbacks Resolves PUB-2061 --- ably.d.ts | 158 +++- scripts/moduleReport.ts | 1 + src/plugins/objects/batchcontext.ts | 2 +- src/plugins/objects/constants.ts | 1 + src/plugins/objects/instance.ts | 24 +- src/plugins/objects/livecounter.ts | 17 +- src/plugins/objects/livemap.ts | 122 ++- src/plugins/objects/liveobject.ts | 193 ++++- src/plugins/objects/objectmessage.ts | 19 + src/plugins/objects/objectspool.ts | 18 +- src/plugins/objects/pathobject.ts | 44 +- .../objects/pathobjectsubscriptionregister.ts | 209 +++++ src/plugins/objects/realtimeobject.ts | 60 +- test/realtime/objects.test.js | 769 ++++++++++++++++++ 14 files changed, 1587 insertions(+), 50 deletions(-) create mode 100644 src/plugins/objects/constants.ts create mode 100644 src/plugins/objects/pathobjectsubscriptionregister.ts diff --git a/ably.d.ts b/ably.d.ts index 0b996b6b91..7b2089d7e8 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -1645,6 +1645,13 @@ export type DeregisterCallback = (device: DeviceDetails, callback: StandardCallb */ export type ErrorCallback = (error: ErrorInfo | null) => void; +/** + * A callback which returns only a single argument - an event object. + * + * @param event - The event which triggered the callback. + */ +export type EventCallback = (event: T) => void; + /** * A callback used in {@link LiveObjectDeprecated} to listen for updates to the object. * @@ -2436,7 +2443,31 @@ interface PathObjectBase<_T extends Value> { * * @experimental */ - compact(): any | undefined; + compact(): any; + + /** + * Registers a listener that is called each time the object or a primitive value at this path is updated. + * + * The provided listener receives a {@link PathObject} representing the updated path, + * and, if applicable, an {@link ObjectMessage} that carried the operation that led to the change. + * + * By default, subscriptions observe nested changes, but you can configure the observation depth + * using the `options` parameter. + * + * A PathObject subscription observes whichever value currently exists at this path. + * The subscription remains active even if the path temporarily does not resolve to any value + * (for example, if an entry is removed from a map). If the object instance at this path changes, + * the subscription automatically switches to observe the new instance and stops observing the old one. + * + * @param listener - An event listener function. + * @param options - Optional subscription configuration. + * @returns A {@link SubscribeResponse} object that allows the provided listener to be deregistered from future updates. + * @experimental + */ + subscribe( + listener: EventCallback, + options?: PathObjectSubscriptionOptions, + ): SubscribeResponse; } /** @@ -2646,7 +2677,7 @@ export interface AnyPathObject * @returns The object instance at this path, or `undefined` if none exists. * @experimental */ - instance(): AnyInstance | undefined; + instance(): Instance | undefined; } /** @@ -2678,7 +2709,7 @@ export interface LiveMapOperations = Record = Record { +interface InstanceBase { /** * Get the object ID of this instance. * @@ -2848,6 +2879,28 @@ interface InstanceBase<_T extends Value> { * @experimental */ compact(): any; + + /** + * Registers a listener that is called each time this instance is updated. + * + * If the underlying instance at runtime is not a {@link LiveObject}, this method throws an error. + * + * The provided listener receives an {@link Instance} representing the updated object, + * and, if applicable, an {@link ObjectMessage} that carried the operation that led to the change. + * + * Instance subscriptions track a specific object instance regardless of its location. + * The subscription follows the instance if it is moved within the broader structure + * (for example, between map entries). + * + * If the instance is deleted from the channel object entirely (i.e., tombstoned), + * the listener is called with the corresponding delete operation before + * automatically deregistering. + * + * @param listener - An event listener function. + * @returns A {@link SubscribeResponse} object that allows the provided listener to be deregistered from future updates. + * @experimental + */ + subscribe(listener: EventCallback>): SubscribeResponse; } /** @@ -3045,6 +3098,93 @@ export type Instance = [T] extends [LiveMap] ? PrimitiveInstance : AnyInstance; +/** + * The event object passed to a {@link PathObject} subscription listener. + */ +export type PathObjectSubscriptionEvent = { + /** The {@link PathObject} representing the updated path. */ + object: PathObject; + /** The {@link ObjectMessage} that carried the operation that led to the change, if applicable. */ + message?: ObjectMessage; +}; + +/** + * Options that can be provided to {@link PathObjectBase.subscribe | PathObject.subscribe}. + */ +export interface PathObjectSubscriptionOptions { + /** + * The number of levels deep to observe changes in nested children. + * + * - If `undefined` (default), there is no depth limit, and changes at any depth + * within nested children will be observed. + * - A depth of `1` (the minimum) means that only changes to the object at the subscribed path + * itself will be observed, not changes to its children. + */ + depth?: number; +} + +/** + * The event object passed to an {@link Instance} subscription listener. + */ +export type InstanceSubscriptionEvent = { + /** The {@link Instance} representing the updated object. */ + object: Instance; + /** The {@link ObjectMessage} that carried the operation that led to the change, if applicable. */ + message?: ObjectMessage; +}; + +/** + * An object message that carried an operation. + */ +export interface ObjectMessage { + /** + * Unique ID assigned by Ably to this object message. + */ + id: string; + /** + * The client ID of the publisher of this object message (if any). + */ + clientId?: string; + /** + * The connection ID of the publisher of this object message (if any). + */ + connectionId?: string; + /** + * Timestamp of when the object message was received by Ably, as milliseconds since the Unix epoch. + */ + timestamp: number; + /** + * The name of the channel the object message was published to. + */ + channel: string; + /** + * An opaque string that uniquely identifies this object message. + */ + serial?: string; + /** + * A timestamp from the {@link serial} field. + */ + serialTimestamp?: number; + /** + * An opaque string that uniquely identifies the Ably site the object message was published to. + */ + siteCode?: string; + /** + * A JSON object of arbitrary key-value pairs that may contain metadata, and/or ancillary payloads. Valid payloads include `headers`. + */ + extras?: { + /** + * A set of key–value pair headers included with this object message. + */ + headers?: Record; + [key: string]: any; + }; + /** + * The operation payload of the object message. + */ + payload: any; +} + /** * The default type for the entrypoint {@link LiveMapDeprecated} object on a channel, based on the globally defined {@link AblyObjectsTypes} interface. * diff --git a/scripts/moduleReport.ts b/scripts/moduleReport.ts index aed831715c..db927ee17d 100644 --- a/scripts/moduleReport.ts +++ b/scripts/moduleReport.ts @@ -341,6 +341,7 @@ async function checkObjectsPluginFiles() { 'src/plugins/objects/objectmessage.ts', 'src/plugins/objects/objectspool.ts', 'src/plugins/objects/pathobject.ts', + 'src/plugins/objects/pathobjectsubscriptionregister.ts', 'src/plugins/objects/realtimeobject.ts', 'src/plugins/objects/syncobjectsdatapool.ts', ]); diff --git a/src/plugins/objects/batchcontext.ts b/src/plugins/objects/batchcontext.ts index 4bc7014279..358e8ff78a 100644 --- a/src/plugins/objects/batchcontext.ts +++ b/src/plugins/objects/batchcontext.ts @@ -2,10 +2,10 @@ import type BaseClient from 'common/lib/client/baseclient'; import type * as API from '../../../ably'; import { BatchContextLiveCounter } from './batchcontextlivecounter'; import { BatchContextLiveMap } from './batchcontextlivemap'; +import { ROOT_OBJECT_ID } from './constants'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; import { ObjectMessage } from './objectmessage'; -import { ROOT_OBJECT_ID } from './objectspool'; import { RealtimeObject } from './realtimeobject'; export class BatchContext { diff --git a/src/plugins/objects/constants.ts b/src/plugins/objects/constants.ts new file mode 100644 index 0000000000..a62a2ca70d --- /dev/null +++ b/src/plugins/objects/constants.ts @@ -0,0 +1 @@ +export const ROOT_OBJECT_ID = 'root'; diff --git a/src/plugins/objects/instance.ts b/src/plugins/objects/instance.ts index 80dabd3602..0a36199f9a 100644 --- a/src/plugins/objects/instance.ts +++ b/src/plugins/objects/instance.ts @@ -1,10 +1,18 @@ import type BaseClient from 'common/lib/client/baseclient'; -import type { AnyInstance, Instance, Primitive, Value } from '../../../ably'; +import type { AnyInstance, EventCallback, Instance, InstanceSubscriptionEvent, Primitive, Value } from '../../../ably'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; -import { LiveObject } from './liveobject'; +import { LiveObject, LiveObjectUpdate, SubscribeResponse } from './liveobject'; +import { ObjectMessage } from './objectmessage'; import { RealtimeObject } from './realtimeobject'; +export interface InstanceEvent { + /** Object message that caused this event */ + message?: ObjectMessage; + /** Compact representation of an update to the object */ + update: Omit; +} + export class DefaultInstance implements AnyInstance { protected _client: BaseClient; @@ -136,4 +144,16 @@ export class DefaultInstance implements AnyInstance { } return this._value.decrement(amount ?? 1); } + + subscribe(listener: EventCallback>): SubscribeResponse { + if (!(this._value instanceof LiveObject)) { + throw new this._client.ErrorInfo('Cannot subscribe to a non-LiveObject instance', 92007, 400); + } + return this._value.instanceSubscribe((event: InstanceEvent) => { + listener({ + object: this as unknown as Instance, + message: event.message?.toUserFacingMessage(this._realtimeObject.getChannel(), event.update), + }); + }); + } } diff --git a/src/plugins/objects/livecounter.ts b/src/plugins/objects/livecounter.ts index 4895aebdcd..0416e13218 100644 --- a/src/plugins/objects/livecounter.ts +++ b/src/plugins/objects/livecounter.ts @@ -8,6 +8,7 @@ export interface LiveCounterData extends LiveObjectData { export interface LiveCounterUpdate extends LiveObjectUpdate { update: { amount: number }; + _type: 'LiveCounterUpdate'; } /** @spec RTLC1, RTLC2 */ @@ -126,7 +127,7 @@ export class LiveCounter extends LiveObject return; } - let update: LiveCounterUpdate | LiveObjectUpdateNoop; + let update: LiveCounterUpdate | LiveObjectUpdateNoop = { noop: true }; switch (op.action) { case ObjectOperationAction.COUNTER_CREATE: update = this._applyCounterCreate(op, msg); @@ -143,7 +144,7 @@ export class LiveCounter extends LiveObject break; case ObjectOperationAction.OBJECT_DELETE: - update = this._applyObjectDelete(msg); + this._applyObjectDelete(msg); break; default: @@ -154,7 +155,7 @@ export class LiveCounter extends LiveObject ); } - this.notifyUpdated(update); + this.notifyUpdated(update, msg); } /** @@ -240,7 +241,7 @@ export class LiveCounter extends LiveObject protected _updateFromDataDiff(prevDataRef: LiveCounterData, newDataRef: LiveCounterData): LiveCounterUpdate { const counterDiff = newDataRef.data - prevDataRef.data; - return { update: { amount: counterDiff } }; + return { update: { amount: counterDiff }, _type: 'LiveCounterUpdate' }; } protected _mergeInitialDataFromCreateOperation( @@ -258,6 +259,7 @@ export class LiveCounter extends LiveObject update: { amount: objectOperation.counter?.count ?? 0 }, clientId: msg.clientId, connectionId: msg.connectionId, + _type: 'LiveCounterUpdate', }; } @@ -291,6 +293,11 @@ export class LiveCounter extends LiveObject private _applyCounterInc(op: ObjectsCounterOp, msg: ObjectMessage): LiveCounterUpdate { this._dataRef.data += op.amount; - return { update: { amount: op.amount }, clientId: msg.clientId, connectionId: msg.connectionId }; + return { + update: { amount: op.amount }, + clientId: msg.clientId, + connectionId: msg.connectionId, + _type: 'LiveCounterUpdate', + }; } } diff --git a/src/plugins/objects/livemap.ts b/src/plugins/objects/livemap.ts index 731f35af8a..efd08f3d0a 100644 --- a/src/plugins/objects/livemap.ts +++ b/src/plugins/objects/livemap.ts @@ -42,6 +42,7 @@ export interface LiveMapData extends LiveObjectData { export interface LiveMapUpdate extends LiveObjectUpdate { update: { [keyName in keyof T & string]?: 'updated' | 'removed' }; + _type: 'LiveMapUpdate'; } /** @spec RTLM1, RTLM2 */ @@ -379,7 +380,7 @@ export class LiveMap extends LiveObject | LiveObjectUpdateNoop; + let update: LiveMapUpdate | LiveObjectUpdateNoop = { noop: true }; switch (op.action) { case ObjectOperationAction.MAP_CREATE: update = this._applyMapCreate(op, msg); @@ -406,7 +407,7 @@ export class LiveMap extends LiveObject extends LiveObject extends LiveObject extends LiveObject this._dataRef.data.delete(x)); } + /** + * Override clearData to handle parent reference cleanup when this LiveMap is tombstoned. + * + * @internal + */ + clearData(): LiveMapUpdate { + // Remove all parent references for objects this map was referencing + for (const [key, entry] of this._dataRef.data.entries()) { + if (entry.data && 'objectId' in entry.data) { + const referencedObject = this._realtimeObject.getPool().get(entry.data.objectId); + if (referencedObject) { + referencedObject.removeParentReference(this, key); + } + } + } + + // Call the parent clearData method + return super.clearData(); + } + /** @spec RTLM4 */ protected _getZeroValueData(): LiveMapData { return { data: new Map() }; } protected _updateFromDataDiff(prevDataRef: LiveMapData, newDataRef: LiveMapData): LiveMapUpdate { - const update: LiveMapUpdate = { update: {} }; + const update: LiveMapUpdate = { update: {}, _type: 'LiveMapUpdate' }; for (const [key, currentEntry] of prevDataRef.data.entries()) { const typedKey: keyof T & string = key; @@ -588,10 +613,15 @@ export class LiveMap extends LiveObject = { update: {}, clientId: msg.clientId, connectionId: msg.connectionId }; + const aggregatedUpdate: LiveMapUpdate = { + update: {}, + clientId: msg.clientId, + connectionId: msg.connectionId, + _type: 'LiveMapUpdate', + }; // RTLM6d1 // in order to apply MAP_CREATE op for an existing map, we should merge their underlying entries keys. // we can do this by iterating over entries from MAP_CREATE op and apply changes on per-key basis as if we had MAP_SET, MAP_REMOVE operations. @@ -700,6 +730,15 @@ export class LiveMap extends LiveObject extends LiveObject = { update: {}, clientId: msg.clientId, connectionId: msg.connectionId }; + // Add parent reference to the new object (if it's an object reference) + if ('objectId' in liveData) { + const newReferencedObject = this._realtimeObject.getPool().get(liveData.objectId); + if (newReferencedObject) { + newReferencedObject.addParentReference(this, op.key); + } + } + + const update: LiveMapUpdate = { + update: {}, + clientId: msg.clientId, + connectionId: msg.connectionId, + _type: 'LiveMapUpdate', + }; const typedKey: keyof T & string = op.key; update.update[typedKey] = 'updated'; @@ -757,6 +809,15 @@ export class LiveMap extends LiveObject extends LiveObject = { update: {}, clientId: msg.clientId, connectionId: msg.connectionId }; + const update: LiveMapUpdate = { + update: {}, + clientId: msg.clientId, + connectionId: msg.connectionId, + _type: 'LiveMapUpdate', + }; const typedKey: keyof T & string = op.key; update.update[typedKey] = 'removed'; @@ -898,4 +964,44 @@ export class LiveMap extends LiveObject, previousDataRef: LiveMapData): void { + for (const [key, changeType] of Object.entries(update.update)) { + if (changeType === 'removed') { + // Key was removed - remove parent reference from the old object if it was referencing one + const previousEntry = previousDataRef.data.get(key); + if (previousEntry?.data && 'objectId' in previousEntry.data) { + const oldReferencedObject = this._realtimeObject.getPool().get(previousEntry.data.objectId); + if (oldReferencedObject) { + oldReferencedObject.removeParentReference(this, key); + } + } + } + + if (changeType === 'updated') { + // Key was updated - need to handle both removal of old reference and addition of new reference + const previousEntry = previousDataRef.data.get(key); + const newEntry = this._dataRef.data.get(key); + + // Remove old parent reference if there was one + if (previousEntry?.data && 'objectId' in previousEntry.data) { + const oldReferencedObject = this._realtimeObject.getPool().get(previousEntry.data.objectId); + if (oldReferencedObject) { + oldReferencedObject.removeParentReference(this, key); + } + } + + // Add new parent reference if the new value references an object + if (newEntry?.data && 'objectId' in newEntry.data) { + const newReferencedObject = this._realtimeObject.getPool().get(newEntry.data.objectId); + if (newReferencedObject) { + newReferencedObject.addParentReference(this, key); + } + } + } + } + } } diff --git a/src/plugins/objects/liveobject.ts b/src/plugins/objects/liveobject.ts index 9dad5e1dcc..61eda61240 100644 --- a/src/plugins/objects/liveobject.ts +++ b/src/plugins/objects/liveobject.ts @@ -1,6 +1,11 @@ import type BaseClient from 'common/lib/client/baseclient'; import type EventEmitter from 'common/lib/util/eventemitter'; +import type { EventCallback, LiveMapType } from '../../../ably'; +import { ROOT_OBJECT_ID } from './constants'; +import { InstanceEvent } from './instance'; +import { LiveMapUpdate } from './livemap'; import { ObjectData, ObjectMessage, ObjectOperation } from './objectmessage'; +import { PathEvent } from './pathobjectsubscriptionregister'; import { RealtimeObject } from './realtimeobject'; export enum LiveObjectSubscriptionEvent { @@ -12,6 +17,7 @@ export interface LiveObjectData { } export interface LiveObjectUpdate { + _type: 'LiveMapUpdate' | 'LiveCounterUpdate'; update: any; clientId?: string; connectionId?: string; @@ -43,6 +49,7 @@ export abstract class LiveObject< > { protected _client: BaseClient; protected _subscriptions: EventEmitter; + protected _instanceSubscriptions: EventEmitter; protected _lifecycleEvents: EventEmitter; protected _objectId: string; /** @@ -54,6 +61,11 @@ export abstract class LiveObject< protected _createOperationIsMerged: boolean; private _tombstone: boolean; private _tombstonedAt: number | undefined; + /** + * Track parent references - which LiveMap objects contain this object and at which keys. + * Multiple parents can reference the same object, so we use a Map of parent to Set of keys for efficient lookups. + */ + private _parentReferences: Map>; protected constructor( protected _realtimeObject: RealtimeObject, @@ -61,6 +73,7 @@ export abstract class LiveObject< ) { this._client = this._realtimeObject.getClient(); this._subscriptions = new this._client.EventEmitter(this._client.logger); + this._instanceSubscriptions = new this._client.EventEmitter(this._client.logger); this._lifecycleEvents = new this._client.EventEmitter(this._client.logger); this._objectId = objectId; this._dataRef = this._getZeroValueData(); @@ -68,6 +81,7 @@ export abstract class LiveObject< this._siteTimeserials = {}; this._createOperationIsMerged = false; this._tombstone = false; + this._parentReferences = new Map>(); } subscribe(listener: (update: TUpdate) => void): SubscribeResponse { @@ -82,6 +96,19 @@ export abstract class LiveObject< return { unsubscribe }; } + // TODO: replace obsolete .subscribe call with this one when we completely remove previous API and switch to path-based one + instanceSubscribe(listener: EventCallback): SubscribeResponse { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + + this._instanceSubscriptions.on(LiveObjectSubscriptionEvent.updated, listener); + + const unsubscribe = () => { + this._instanceSubscriptions.off(LiveObjectSubscriptionEvent.updated, listener); + }; + + return { unsubscribe }; + } + unsubscribe(listener: (update: TUpdate) => void): void { // this public API method can be called without specific configuration, so checking for invalid settings is unnecessary. @@ -136,16 +163,18 @@ export abstract class LiveObject< /** * Emits the {@link LiveObjectSubscriptionEvent.updated} event with provided update object if it isn't a noop. + * Also notifies the path object subscriptions about path-based events. * * @internal */ - notifyUpdated(update: TUpdate | LiveObjectUpdateNoop): void { + notifyUpdated(update: TUpdate | LiveObjectUpdateNoop, objectMessage?: ObjectMessage): void { // should not emit update event if update was noop if ((update as LiveObjectUpdateNoop).noop) { return; } - this._subscriptions.emit(LiveObjectSubscriptionEvent.updated, update); + this._notifyInstanceSubscriptions(update as TUpdate, objectMessage); + this._notifyPathSubscriptions(update as TUpdate, objectMessage); } /** @@ -153,7 +182,7 @@ export abstract class LiveObject< * * @internal */ - tombstone(objectMessage: ObjectMessage): TUpdate { + tombstone(objectMessage: ObjectMessage): void { this._tombstone = true; if (objectMessage.serialTimestamp != null) { this._tombstonedAt = objectMessage.serialTimestamp; @@ -171,7 +200,10 @@ export abstract class LiveObject< update.connectionId = objectMessage.connectionId; this._lifecycleEvents.emit(LiveObjectLifecycleEvent.deleted); - return update; + // notify subscribers about the delete operation and then deregister all listeners + this.notifyUpdated(update, objectMessage); + this._subscriptions.off(); + this._instanceSubscriptions.off(); } /** @@ -197,6 +229,102 @@ export abstract class LiveObject< return this._updateFromDataDiff(previousDataRef, this._dataRef); } + /** + * Add a parent reference indicating that this object is referenced by the given parent LiveMap at the specified key. + * + * @internal + */ + addParentReference(parent: LiveObject, key: string): void { + const keys = this._parentReferences.get(parent); + + if (keys) { + keys.add(key); + } else { + this._parentReferences.set(parent, new Set([key])); + } + } + + /** + * Remove a parent reference indicating that this object is no longer referenced by the given parent LiveMap at the specified key. + * + * @internal + */ + removeParentReference(parent: LiveObject, key: string): void { + const keys = this._parentReferences.get(parent); + + if (keys) { + keys.delete(key); + // If no more keys for this parent, remove the parent entry entirely + if (keys.size === 0) { + this._parentReferences.delete(parent); + } + } + } + + /** + * Remove all parent references for a specific parent (when parent is being deleted or cleared). + * + * @internal + */ + removeParentReferenceAll(parent: LiveObject): void { + this._parentReferences.delete(parent); + } + + /** + * Clears all parent references for this object. + * + * @internal + */ + clearParentReferences(): void { + this._parentReferences.clear(); + } + + /** + * Calculates and returns all possible paths to this object from the root object by traversing up the parent hierarchy. + * Uses iterative DFS with an explicit stack. Each path is represented as an array of keys from root to this object. + * + * @internal + */ + getFullPaths(): string[][] { + const paths: string[][] = []; + + const stack: { obj: LiveObject; currentPath: string[]; visited: Set }[] = [ + { obj: this, currentPath: [], visited: new Set() }, + ]; + + while (stack.length > 0) { + const { obj, currentPath, visited } = stack.pop()!; + + // Check for cyclic references + if (visited.has(obj)) { + continue; // Skip this path to prevent infinite loops + } + + // Create new visited set for this path + const newVisited = new Set(visited); + newVisited.add(obj); + + if (obj.getObjectId() === ROOT_OBJECT_ID) { + // Reached the root object, add the current path + paths.push(currentPath); + continue; + } + + // Otherwise, add work items for each parent-key combination to the stack + for (const [parent, keys] of obj._parentReferences) { + for (const key of keys) { + stack.push({ + obj: parent, + currentPath: [key, ...currentPath], + visited: newVisited, + }); + } + } + } + + return paths; + } + /** * Returns true if the given serial indicates that the operation to which it belongs should be applied to the object. * @@ -216,8 +344,61 @@ export abstract class LiveObject< return !siteSerial || opSerial > siteSerial; } - protected _applyObjectDelete(objectMessage: ObjectMessage): TUpdate { - return this.tombstone(objectMessage); + protected _applyObjectDelete(objectMessage: ObjectMessage): void { + this.tombstone(objectMessage); + } + + private _updateWithoutType(update: TUpdate): Omit { + const { _type, ...publicUpdate } = update as TUpdate; + return publicUpdate; + } + + private _notifyInstanceSubscriptions(update: TUpdate, objectMessage?: ObjectMessage): void { + this._subscriptions.emit(LiveObjectSubscriptionEvent.updated, this._updateWithoutType(update as TUpdate)); + + const event: InstanceEvent = { + message: objectMessage, + update: this._updateWithoutType(update), + }; + this._instanceSubscriptions.emit(LiveObjectSubscriptionEvent.updated, event); + } + + /** + * Notifies path-based subscriptions about changes to this object. + * For LiveMapUpdate events, also creates non-bubbling events for each updated key. + */ + private _notifyPathSubscriptions(update: TUpdate, objectMessage?: ObjectMessage): void { + const paths = this.getFullPaths(); + + if (paths.length === 0) { + // No paths to this object, skip notification + return; + } + + const pathEvents: PathEvent[] = paths.map((path) => ({ + path, + message: objectMessage, + update: this._updateWithoutType(update), + bubbles: true, + })); + + // For LiveMapUpdate, also create non-bubbling events for each updated key + if (update._type === 'LiveMapUpdate') { + const updatedKeys = Object.keys((update as LiveMapUpdate).update); + + for (const key of updatedKeys) { + for (const basePath of paths) { + pathEvents.push({ + path: [...basePath, key], + message: objectMessage, + bubbles: false, + // Don't include update object as it may include updates for other keys + }); + } + } + } + + this._realtimeObject.getPathObjectSubscriptionRegister().notifyPathEvents(pathEvents); } /** diff --git a/src/plugins/objects/objectmessage.ts b/src/plugins/objects/objectmessage.ts index 5b85ec83cb..ffcd14140a 100644 --- a/src/plugins/objects/objectmessage.ts +++ b/src/plugins/objects/objectmessage.ts @@ -1,8 +1,11 @@ import type BaseClient from 'common/lib/client/baseclient'; +import type RealtimeChannel from 'common/lib/client/realtimechannel'; import type { MessageEncoding } from 'common/lib/types/basemessage'; import type * as Utils from 'common/lib/util/utils'; import type { Bufferlike } from 'common/platform'; +import type * as API from '../../../ably'; import type { JsonArray, JsonObject } from '../../../ably'; +import { LiveObjectUpdate } from './liveobject'; export type EncodeObjectDataFunction = (data: ObjectData | WireObjectData) => WireObjectData; @@ -412,6 +415,22 @@ export class ObjectMessage { toString(): string { return strMsg(this, 'ObjectMessage'); } + + toUserFacingMessage(channel: RealtimeChannel, update?: Omit): API.ObjectMessage { + return { + id: this.id!, + clientId: this.clientId, + connectionId: this.connectionId, + timestamp: this.timestamp!, + channel: channel.name, + serial: this.serial, + serialTimestamp: this.serialTimestamp, + siteCode: this.siteCode, + extras: this.extras, + // TODO: provide REST API like type payload that describes the operation on an object + payload: update, + }; + } } /** diff --git a/src/plugins/objects/objectspool.ts b/src/plugins/objects/objectspool.ts index 4c7fa05e0c..854647d121 100644 --- a/src/plugins/objects/objectspool.ts +++ b/src/plugins/objects/objectspool.ts @@ -1,4 +1,6 @@ import type BaseClient from 'common/lib/client/baseclient'; +import type * as API from '../../../ably'; +import { ROOT_OBJECT_ID } from './constants'; import { DEFAULTS } from './defaults'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; @@ -6,8 +8,6 @@ import { LiveObject } from './liveobject'; import { ObjectId } from './objectid'; import { RealtimeObject } from './realtimeobject'; -export const ROOT_OBJECT_ID = 'root'; - /** * @internal * @spec RTO3 @@ -31,6 +31,18 @@ export class ObjectsPool { return this._pool.get(objectId); } + getRoot(): LiveMap { + return this._pool.get(ROOT_OBJECT_ID) as LiveMap; + } + + /** + * Returns all objects in the pool as an iterable. + * Used internally for operations that need to process all objects. + */ + getAll(): IterableIterator { + return this._pool.values(); + } + /** * Deletes objects from the pool for which object ids are not found in the provided array of ids. */ @@ -51,7 +63,7 @@ export class ObjectsPool { */ resetToInitialPool(emitUpdateEvents: boolean): void { // clear the pool first and keep the root object - const root = this._pool.get(ROOT_OBJECT_ID)!; + const root = this.getRoot(); this._pool.clear(); this._pool.set(root.getObjectId(), root); diff --git a/src/plugins/objects/pathobject.ts b/src/plugins/objects/pathobject.ts index 88d2bf9147..04249baa0c 100644 --- a/src/plugins/objects/pathobject.ts +++ b/src/plugins/objects/pathobject.ts @@ -1,10 +1,19 @@ import type BaseClient from 'common/lib/client/baseclient'; import type * as API from '../../../ably'; -import type { AnyInstance, AnyPathObject, PathObject, Primitive, Value } from '../../../ably'; +import type { + AnyPathObject, + EventCallback, + Instance, + PathObject, + PathObjectSubscriptionEvent, + PathObjectSubscriptionOptions, + Primitive, + Value, +} from '../../../ably'; import { DefaultInstance } from './instance'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; -import { LiveObject } from './liveobject'; +import { LiveObject, SubscribeResponse } from './liveobject'; import { RealtimeObject } from './realtimeobject'; /** @@ -12,7 +21,7 @@ import { RealtimeObject } from './realtimeobject'; * Provides a generic implementation that can handle any type of PathObject operations. */ export class DefaultPathObject implements AnyPathObject { - protected _client: BaseClient; + private _client: BaseClient; private _path: string[]; constructor( @@ -145,13 +154,13 @@ export class DefaultPathObject implements AnyPathObject } } - instance(): AnyInstance | undefined { + instance(): Instance | undefined { try { const value = this._resolvePath(this._path); if (value instanceof LiveObject) { // only return an Instance for LiveObject values - return new DefaultInstance(this._realtimeObject, value); + return new DefaultInstance(this._realtimeObject, value) as unknown as Instance; } // return undefined for non live objects @@ -288,6 +297,31 @@ export class DefaultPathObject implements AnyPathObject return resolved.decrement(amount ?? 1); } + /** + * Subscribes to changes to the object (and, by default, its children) or to a primitive value at this path. + * + * PathObject subscriptions rely on LiveObject instances to broadcast updates through a subscription + * registry for the paths they occupy in the object graph. These updates are then routed to the appropriate + * PathObject subscriptions based on their paths. + * + * When the underlying object or primitive value at this path is changed via an update to its parent + * collection (for example, if a new LiveCounter instance is set at this path, or a key's value is + * changed in a parent LiveMap), a subscription to this path will receive a separate **non-bubbling** + * event indicating the change. This event is not propagated to parent path subscriptions, as they will + * receive their own event for changes made directly to the object at their respective paths. + * + * PathObject subscriptions observe nested changes by default. Optional `depth` parameter can be provided + * to control this behavior. A subscription depth of `1` means that only direct updates to the underlying + * object - and changes that overwrite the value at this path (via parent object updates) - will trigger events. + */ + + subscribe( + listener: EventCallback, + options?: PathObjectSubscriptionOptions, + ): SubscribeResponse { + return this._realtimeObject.getPathObjectSubscriptionRegister().subscribe(this._path, listener, options ?? {}); + } + private _resolvePath(path: string[]): Value { // TODO: remove type assertion when internal LiveMap is updated to support new path based type system let current: Value = this._root as unknown as API.LiveMap; diff --git a/src/plugins/objects/pathobjectsubscriptionregister.ts b/src/plugins/objects/pathobjectsubscriptionregister.ts new file mode 100644 index 0000000000..12b7659bbe --- /dev/null +++ b/src/plugins/objects/pathobjectsubscriptionregister.ts @@ -0,0 +1,209 @@ +import type BaseClient from 'common/lib/client/baseclient'; +import type { EventCallback, PathObjectSubscriptionEvent, PathObjectSubscriptionOptions } from '../../../ably'; +import { LiveObjectUpdate, SubscribeResponse } from './liveobject'; +import { ObjectMessage } from './objectmessage'; +import { DefaultPathObject } from './pathobject'; +import { RealtimeObject } from './realtimeobject'; + +/** + * Internal subscription entry that tracks a listener and its options + */ +export interface SubscriptionEntry { + /** The listener function to call when events match */ + listener: EventCallback; + /** The subscription options including depth */ + options: PathObjectSubscriptionOptions; + /** The path this subscription is registered for */ + path: string[]; +} + +/** + * Event data that LiveObjects provide when notifying of changes + */ +export interface PathEvent { + /** The path where the event occurred */ + path: string[]; + /** Object message that caused this event */ + message?: ObjectMessage; + /** Compact representation of an update to the object */ + update?: Omit; + /** Whether this event should bubble up to parent paths. Defaults to true if not specified. */ + bubbles?: boolean; +} + +/** + * Registry for managing PathObject subscriptions and routing events to appropriate listeners. + * Handles depth-based filtering for subscription matching. + * + * @internal + */ +export class PathObjectSubscriptionRegister { + private _client: BaseClient; + private _subscriptions: Map = new Map(); + private _nextSubscriptionId = 0; + + constructor(private _realtimeObject: RealtimeObject) { + this._client = this._realtimeObject.getClient(); + } + + /** + * Registers a new subscription for the given path. + * + * @param path - Array of keys representing the path to subscribe to + * @param listener - Function to call when matching events occur + * @param options - Subscription options including depth parameter + * @returns Unsubscribe function + */ + subscribe( + path: string[], + listener: EventCallback, + options: PathObjectSubscriptionOptions, + ): SubscribeResponse { + if (options != null && typeof options !== 'object') { + throw new this._client.ErrorInfo('Subscription options must be an object', 40000, 400); + } + + if (options.depth !== undefined && options.depth <= 0) { + throw new this._client.ErrorInfo( + 'Subscription depth must be greater than 0 or undefined for infinite depth', + 40003, + 400, + ); + } + + const subscriptionId = (this._nextSubscriptionId++).toString(); + const entry: SubscriptionEntry = { + listener, + options, + path: [...path], // Make a copy to avoid external mutations + }; + + this._subscriptions.set(subscriptionId, entry); + + return { + unsubscribe: () => { + this._subscriptions.delete(subscriptionId); + }, + }; + } + + /** + * Notifies all matching subscriptions about an event that occurred at the specified path(s). + * + * @param events - Array of path events to process + */ + notifyPathEvents(events: PathEvent[]): void { + for (const event of events) { + this._processEvent(event); + } + } + + /** + * Processes a single path event and calls all matching subscription listeners. + */ + private _processEvent(event: PathEvent): void { + for (const subscription of this._subscriptions.values()) { + if (!this._shouldNotifySubscription(subscription, event)) { + continue; + } + + try { + const subscriptionEvent: PathObjectSubscriptionEvent = { + object: new DefaultPathObject(this._realtimeObject, this._realtimeObject.getPool().getRoot(), event.path), + message: event.message?.toUserFacingMessage(this._realtimeObject.getChannel(), event.update), + }; + + subscription.listener(subscriptionEvent); + } catch (error) { + // Log error but don't let one subscription failure affect others + this._client.Logger.logAction( + this._client.logger, + this._client.Logger.LOG_MINOR, + 'PathObjectSubscriptionRegister._processEvent()', + `Error in PathObject subscription listener; path=${JSON.stringify(event.path)}, error=${error}`, + ); + } + } + } + + /** + * Determines if a subscription should be notified about an event at the given path. + * Implements depth-based filtering logic and bubbling control. + * + * Depth examples (when event.bubbles is true): + * - subscription at ["users"] with depth=undefined: matches ["users"], ["users", "emma"], ["users", "emma", "visits"], etc. + * - subscription at ["users"] with depth=1: matches ["users"] only + * - subscription at ["users"] with depth=2: matches ["users"], ["users", "emma"] only + * - subscription at ["users"] with depth=3: matches ["users"], ["users", "emma"], ["users", "emma", "visits"] only + * + * Non-bubbling examples (when event.bubbles is false): + * - Event at ["users", "emma"] with bubbles=false: + * - subscription at ["users"]: NOT triggered (no bubbling to parent) + * - subscription at ["users", "emma"]: triggered (exact path match) + * + * The depth calculation is: eventPath.length - subscriptionPath.length + 1 + * This means: + * - Same level (["users"] -> ["users"]): 1 - 1 + 1 = 1 (depth=1) + * - One level deeper (["users"] -> ["users", "emma"]): 2 - 1 + 1 = 2 (depth=2) + * - Two levels deeper (["users"] -> ["users", "emma", "visits"]): 3 - 1 + 1 = 3 (depth=3) + */ + private _shouldNotifySubscription(subscription: SubscriptionEntry, event: PathEvent): boolean { + const subPath = subscription.path; + const eventPath = event.path; + const depth = subscription.options.depth; + const bubbles = event.bubbles !== false; // Default to true if not specified + + // If event doesn't bubble, only match exact paths + if (!bubbles) { + return this._pathsAreEqual(eventPath, subPath); + } + + // Otherwise check if the event path starts with the subscription path + if (!this._pathStartsWith(eventPath, subPath)) { + return false; + } + + // If depth is undefined, allow infinite depth + if (depth === undefined) { + return true; + } + + // Otherwise calculate the relative depth from subscription path to event path + const relativeDepth = eventPath.length - subPath.length + 1; + + // Check if the event is within the allowed depth + return relativeDepth <= depth; + } + + /** + * Checks if eventPath starts with subscriptionPath. + * + * @param eventPath - The path where the event occurred + * @param subscriptionPath - The path that was subscribed to + * @returns true if eventPath starts with subscriptionPath + */ + private _pathStartsWith(eventPath: string[], subscriptionPath: string[]): boolean { + if (subscriptionPath.length > eventPath.length) { + return false; + } + + for (let i = 0; i < subscriptionPath.length; i++) { + if (eventPath[i] !== subscriptionPath[i]) { + return false; + } + } + + return true; + } + + /** + * Checks if two paths are exactly equal. + * + * @param path1 - First path to compare + * @param path2 - Second path to compare + * @returns true if paths are exactly equal + */ + private _pathsAreEqual(path1: string[], path2: string[]): boolean { + return this._client.Utils.arrEquals(path1, path2); + } +} diff --git a/src/plugins/objects/realtimeobject.ts b/src/plugins/objects/realtimeobject.ts index a2ee00f3f2..b8419c1dad 100644 --- a/src/plugins/objects/realtimeobject.ts +++ b/src/plugins/objects/realtimeobject.ts @@ -8,8 +8,9 @@ import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; import { LiveObject, LiveObjectUpdate, LiveObjectUpdateNoop } from './liveobject'; import { ObjectMessage, ObjectOperationAction } from './objectmessage'; -import { ObjectsPool, ROOT_OBJECT_ID } from './objectspool'; +import { ObjectsPool } from './objectspool'; import { DefaultPathObject } from './pathobject'; +import { PathObjectSubscriptionRegister } from './pathobjectsubscriptionregister'; import { SyncObjectsDataPool } from './syncobjectsdatapool'; export enum ObjectsEvent { @@ -53,6 +54,7 @@ export class RealtimeObject { private _currentSyncId: string | undefined; private _currentSyncCursor: string | undefined; private _bufferedObjectOperations: ObjectMessage[]; + private _pathObjectSubscriptionRegister: PathObjectSubscriptionRegister; // Used by tests static _DEFAULTS = DEFAULTS; @@ -66,6 +68,7 @@ export class RealtimeObject { this._objectsPool = new ObjectsPool(this); this._syncObjectsDataPool = new SyncObjectsDataPool(this); this._bufferedObjectOperations = []; + this._pathObjectSubscriptionRegister = new PathObjectSubscriptionRegister(this); // use server-provided objectsGCGracePeriod if available, and subscribe to new connectionDetails that can be emitted as part of the RTN24 this.gcGracePeriod = this._channel.connectionManager.connectionDetails?.objectsGCGracePeriod ?? DEFAULTS.gcGracePeriod; @@ -88,7 +91,7 @@ export class RealtimeObject { await this._eventEmitterInternal.once(ObjectsEvent.synced); // RTO1c } - return this._objectsPool.get(ROOT_OBJECT_ID) as LiveMap; // RTO1d + return this._objectsPool.getRoot(); // RTO1d } // TODO: replace .get call with this one when we have full path object API support. @@ -100,12 +103,7 @@ export class RealtimeObject { await this._eventEmitterInternal.once(ObjectsEvent.synced); // RTO1c } - const pathObject = new DefaultPathObject>( - this, - // TODO: fix LiveMap when internal LiveMap is updated to support new path based type system - this._objectsPool.get(ROOT_OBJECT_ID) as LiveMap, - [], - ); + const pathObject = new DefaultPathObject>(this, this._objectsPool.getRoot(), []); return pathObject; } @@ -174,6 +172,13 @@ export class RealtimeObject { return this._client; } + /** + * @internal + */ + getPathObjectSubscriptionRegister(): PathObjectSubscriptionRegister { + return this._pathObjectSubscriptionRegister; + } + /** * @internal * @spec RTO5 @@ -339,7 +344,11 @@ export class RealtimeObject { } const receivedObjectIds = new Set(); - const existingObjectUpdates: { object: LiveObject; update: LiveObjectUpdate | LiveObjectUpdateNoop }[] = []; + const existingObjectUpdates: { + object: LiveObject; + update: LiveObjectUpdate | LiveObjectUpdateNoop; + message: ObjectMessage; + }[] = []; // RTO5c1 for (const [objectId, entry] of this._syncObjectsDataPool.entries()) { @@ -351,7 +360,7 @@ export class RealtimeObject { const update = existingObject.overrideWithObjectState(entry.objectMessage); // RTO5c1a1 // store updates to call subscription callbacks for all of them once the sync sequence is completed. // this will ensure that clients get notified about the changes only once everything has been applied. - existingObjectUpdates.push({ object: existingObject, update }); + existingObjectUpdates.push({ object: existingObject, update, message: entry.objectMessage }); continue; } @@ -378,8 +387,12 @@ export class RealtimeObject { // RTO5c2 - need to remove LiveObject instances from the ObjectsPool for which objectIds were not received during the sync sequence this._objectsPool.deleteExtraObjectIds([...receivedObjectIds]); + // Rebuild all parent references after sync to ensure all object-to-object references are properly established + // This is necessary because objects may reference other objects that weren't in the pool when they were initially created + this._rebuildAllParentReferences(); + // call subscription callbacks for all updated existing objects - existingObjectUpdates.forEach(({ object, update }) => object.notifyUpdated(update)); + existingObjectUpdates.forEach(({ object, update, message }) => object.notifyUpdated(update, message)); } private _applyObjectMessages(objectMessages: ObjectMessage[]): void { @@ -451,6 +464,31 @@ export class RealtimeObject { this._eventEmitterPublic.emit(event); } + /** + * Rebuilds all parent references in the objects pool. + * This is necessary after sync operations where objects may reference other objects + * that weren't available when the initial parent references were established. + */ + private _rebuildAllParentReferences(): void { + // First, clear all existing parent references + for (const object of this._objectsPool.getAll()) { + object.clearParentReferences(); + } + + // Then, rebuild parent references by examining all objects and their data + for (const object of this._objectsPool.getAll()) { + if (object instanceof LiveMap) { + // For LiveMaps, iterate through their entries and establish parent references + for (const [key, value] of object.entries()) { + if (value instanceof LiveObject) { + value.addParentReference(object, key); + } + } + } + // Note: LiveCounter doesn't reference other objects, so no special handling needed + } + } + private _throwIfInChannelState(channelState: API.ChannelState[]): void { if (channelState.includes(this._channel.state)) { throw this._client.ErrorInfo.fromValues(this._channel.invalidStateError()); diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js index 37a92b26ee..cf41a79ab2 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/objects.test.js @@ -4444,6 +4444,534 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ); }, }, + + { + description: 'PathObject.subscribe() receives events for direct changes to the subscribed path', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + const subscriptionPromise = new Promise((resolve, reject) => { + entryPathObject.subscribe((event) => { + try { + expect(event.object, 'Check event object exists').to.exist; + expect(event.object.path()).to.equal('', 'Check event object path is root'); + expect(event.message, 'Check event message exists').to.exist; + resolve(); + } catch (error) { + reject(error); + } + }); + }); + + await root.set('testKey', 'testValue'); + await subscriptionPromise; + }, + }, + + { + description: 'PathObject.subscribe() receives events for nested changes with unlimited depth by default', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + let eventCount = 0; + const subscriptionPromise = new Promise((resolve, reject) => { + entryPathObject.subscribe((event) => { + try { + eventCount++; + expect(event.object, 'Check event object exists').to.exist; + if (eventCount === 1) { + expect(event.object.path()).to.equal('', 'First event is at root path'); + } else if (eventCount === 2) { + expect(event.object.path()).to.equal('nested', 'Second event is at nested path'); + } else if (eventCount === 3) { + expect(event.object.path()).to.equal('nested.child', 'Third event is at nested.child path'); + resolve(); + } + } catch (error) { + reject(error); + } + }); + }); + + // root level change + let keyUpdatedPromise = waitForMapKeyUpdate(root, 'nested'); + await entryPathObject.set('nested', LiveMap.create()); + await keyUpdatedPromise; + + // nested change + keyUpdatedPromise = waitForMapKeyUpdate(root.get('nested'), 'child'); + await entryPathObject.get('nested').set('child', LiveMap.create()); + await keyUpdatedPromise; + + // nested child change + keyUpdatedPromise = waitForMapKeyUpdate(root.get('nested').get('child'), 'foo'); + await entryPathObject.get('nested').get('child').set('foo', 'bar'); + await keyUpdatedPromise; + + await subscriptionPromise; + }, + }, + + { + description: 'PathObject.subscribe() with depth parameter receives expected events', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + // Create nested structure + let keyUpdatedPromise = waitForMapKeyUpdate(root, 'nested'); + await entryPathObject.set('nested', LiveMap.create({ counter: LiveCounter.create() })); + await keyUpdatedPromise; + + // Create two subscriptions to root, with depth=1 and depth=2 + const subscriptionDepthOnePromise = new Promise((resolve, reject) => { + entryPathObject.subscribe( + (event) => { + try { + expect(event.object.path()).to.equal('', 'First event is at root path for depth=1 subscription'); + resolve(); + } catch (error) { + reject(error); + } + }, + { depth: 1 }, + ); + }); + let eventCount = 0; + const subscriptionDepthTwoPromise = new Promise((resolve, reject) => { + entryPathObject.subscribe( + (event) => { + eventCount++; + try { + if (eventCount === 1) { + expect(event.object.path()).to.equal( + 'nested', + 'First event is at nested path for depth=2 subscription', + ); + } else if (eventCount === 2) { + expect(event.object.path()).to.equal('', 'Second event is at root path for depth=2 subscription'); + resolve(); + } + } catch (error) { + reject(error); + } + }, + { depth: 2 }, + ); + }); + + // Make nested changes couple of levels deep, different subscriptions should get different events + const counterUpdatedPromise = waitForCounterUpdate(root.get('nested').get('counter')); + await entryPathObject.get('nested').get('counter').increment(); + await counterUpdatedPromise; + + keyUpdatedPromise = waitForMapKeyUpdate(root.get('nested'), 'nestedKey'); + await entryPathObject.get('nested').set('nestedKey', 'foo'); + await keyUpdatedPromise; + + // Now make a direct change to the root object, should trigger the callback + keyUpdatedPromise = waitForMapKeyUpdate(root, 'directKey'); + await entryPathObject.set('directKey', 'bar'); + await keyUpdatedPromise; + + await Promise.all([subscriptionDepthOnePromise, subscriptionDepthTwoPromise]); + }, + }, + + { + description: 'PathObject.subscribe() on nested path receives events for that path and its children', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + // Create nested structure + let keyUpdatedPromise = waitForMapKeyUpdate(root, 'nested'); + await entryPathObject.set('nested', LiveMap.create({ counter: LiveCounter.create() })); + await keyUpdatedPromise; + + let eventCount = 0; + const subscriptionPromise = new Promise((resolve, reject) => { + entryPathObject.get('nested').subscribe((event) => { + eventCount++; + try { + if (eventCount === 1) { + expect(event.object.path()).to.equal('nested', 'First event is at nested path'); + } else if (eventCount === 2) { + expect(event.object.path()).to.equal( + 'nested.counter', + 'Second event is for a child of a nested path', + ); + } else if (eventCount === 3) { + expect(event.object.path()).to.equal('nested', 'Third event is at nested path'); + resolve(); + } + } catch (error) { + reject(error); + } + }); + }); + + // root change should not trigger the subscription + keyUpdatedPromise = waitForMapKeyUpdate(root, 'foo'); + await entryPathObject.set('foo', 'bar'); + await keyUpdatedPromise; + + // Next changes should trigger the subscription + keyUpdatedPromise = waitForMapKeyUpdate(root.get('nested'), 'foo'); + await entryPathObject.get('nested').set('foo', 'bar'); + await keyUpdatedPromise; + + const counterUpdatedPromise = waitForCounterUpdate(root.get('nested').get('counter')); + await entryPathObject.get('nested').get('counter').increment(); + await counterUpdatedPromise; + + // If object at the subscribed path is replaced, that should also trigger the subscription + keyUpdatedPromise = waitForMapKeyUpdate(root, 'nested'); + await entryPathObject.set('nested', LiveMap.create()); + await keyUpdatedPromise; + + await subscriptionPromise; + }, + }, + + { + description: 'PathObject.subscribe() works with complex nested paths and escaped dots', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(root, 'escaped\\key'); + await entryPathObject.set('escaped\\key', LiveMap.create({ 'key.with.dots': LiveCounter.create() })); + await keyUpdatedPromise; + + const complexPathObject = entryPathObject.get('escaped\\key').get('key.with.dots'); + const subscriptionPromise = new Promise((resolve, reject) => { + complexPathObject.subscribe((event) => { + try { + expect(event.object.path()).to.equal( + 'escaped\\key.key\\.with\\.dots', + 'Check complex subscription path', + ); + expect(event.message, 'Check event message exists').to.exist; + expect(event.object.value()).to.equal(1, 'Check correct counter value at complex path'); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + + const counterUpdatedPromise = waitForCounterUpdate(root.get('escaped\\key').get('key.with.dots'), 'key1'); + await complexPathObject.increment(); + await counterUpdatedPromise; + + await subscriptionPromise; + }, + }, + + { + // TODO + description: 'PathObject.subscribe() keeps subscription after underlying object is replaced', + action: async (ctx) => {}, + }, + + { + description: 'PathObject.subscribe() on LiveMap path receives set/remove events', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + let keyUpdatedPromise = waitForMapKeyUpdate(root, 'map'); + await entryPathObject.set('map', LiveMap.create()); + await keyUpdatedPromise; + + let eventCount = 0; + const subscriptionPromise = new Promise((resolve, reject) => { + entryPathObject.get('map').subscribe((event) => { + eventCount++; + + try { + expect(event.object.path()).to.equal('map', 'Check map subscription event path'); + expect(event.message, 'Check event message exists').to.exist; + + if (eventCount === 1) { + expect(event.message.operation.action).to.equal('map.set', 'Check first event is MAP_SET'); + } else if (eventCount === 2) { + expect(event.message.operation.action).to.equal('map.remove', 'Check second event is MAP_REMOVE'); + resolve(); + } + } catch (error) { + reject(error); + } + }); + }); + + keyUpdatedPromise = waitForMapKeyUpdate(root.get('map'), 'foo'); + await entryPathObject.get('map').set('foo', 'bar'); + await keyUpdatedPromise; + + keyUpdatedPromise = waitForMapKeyUpdate(root.get('map'), 'foo'); + await entryPathObject.get('map').remove('foo'); + await keyUpdatedPromise; + + await subscriptionPromise; + }, + }, + + { + description: 'PathObject.subscribe() on LiveCounter path receives increment/decrement events', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + let keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); + await entryPathObject.set('counter', LiveCounter.create()); + await keyUpdatedPromise; + + let eventCount = 0; + const subscriptionPromise = new Promise((resolve, reject) => { + entryPathObject.get('counter').subscribe((event) => { + eventCount++; + + try { + expect(event.object.path()).to.equal('counter', 'Check counter subscription event path'); + expect(event.message, 'Check event message exists').to.exist; + + if (eventCount === 1) { + expect(event.message.operation.action).to.equal( + 'counter.inc', + 'Check first event is COUNTER_INC with positive value', + ); + expect(event.message.operation.counterOp.amount).to.equal( + 1, + 'Check first event is COUNTER_INC with positive value', + ); + } else if (eventCount === 2) { + expect(event.message.operation.action).to.equal( + 'counter.inc', + 'Check second event is COUNTER_INC with negative value', + ); + expect(event.message.operation.counterOp.amount).to.equal( + -1, + 'Check first event is COUNTER_INC with positive value', + ); + + resolve(); + } + } catch (error) { + reject(error); + } + }); + }); + + let counterUpdatedPromise = waitForCounterUpdate(root.get('counter')); + await entryPathObject.get('counter').increment(); + await counterUpdatedPromise; + + counterUpdatedPromise = waitForCounterUpdate(root.get('counter')); + await entryPathObject.get('counter').decrement(); + await counterUpdatedPromise; + + await subscriptionPromise; + }, + }, + + { + description: 'PathObject.subscribe() on Primitive path receives changes to the primitive value', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + let keyUpdatedPromise = waitForMapKeyUpdate(root, 'primitive'); + await entryPathObject.set('primitive', 'foo'); + await keyUpdatedPromise; + + let eventCount = 0; + const subscriptionPromise = new Promise((resolve, reject) => { + entryPathObject.get('primitive').subscribe((event) => { + eventCount++; + + try { + expect(event.object.path()).to.equal('primitive', 'Check primitive subscription event path'); + expect(event.message, 'Check event message exists').to.exist; + + if (eventCount === 1) { + expect(event.object.value()).to.equal('baz', 'Check first event has correct value'); + } else if (eventCount === 2) { + expect(event.object.value()).to.equal(42, 'Check second event has correct value'); + } else if (eventCount === 3) { + expect(event.object.value()).to.equal(true, 'Check third event has correct value'); + resolve(); + } + } catch (error) { + reject(error); + } + }); + }); + + // Update to other keys on root should not trigger the subscription + keyUpdatedPromise = waitForMapKeyUpdate(root, 'other'); + await entryPathObject.set('other', 'bar'); + await keyUpdatedPromise; + + // Only changes to the primitive path should trigger the subscription + keyUpdatedPromise = waitForMapKeyUpdate(root, 'primitive'); + await entryPathObject.set('primitive', 'baz'); + await keyUpdatedPromise; + + keyUpdatedPromise = waitForMapKeyUpdate(root, 'primitive'); + await entryPathObject.set('primitive', 42); + await keyUpdatedPromise; + + keyUpdatedPromise = waitForMapKeyUpdate(root, 'primitive'); + await entryPathObject.set('primitive', true); + await keyUpdatedPromise; + + await subscriptionPromise; + }, + }, + + { + description: 'PathObject.subscribe() returns "unsubscribe" function', + action: async (ctx) => { + const { entryPathObject } = ctx; + + const subscribeResponse = entryPathObject.subscribe(() => {}); + + expect(subscribeResponse, 'Check subscribe response exists').to.exist; + expect(subscribeResponse.unsubscribe).to.be.a('function', 'Check unsubscribe is a function'); + + // Should not throw when called + subscribeResponse.unsubscribe(); + }, + }, + + { + description: 'can unsubscribe from PathObject.subscribe() updates using returned "unsubscribe" function', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + let eventCount = 0; + const { unsubscribe } = entryPathObject.subscribe(() => { + eventCount++; + }); + + // Make first change - should receive event + let keyUpdatedPromise = waitForMapKeyUpdate(root, 'key1'); + await entryPathObject.set('key1', 'value1'); + await keyUpdatedPromise; + + unsubscribe(); + + // Make second change - should NOT receive event + keyUpdatedPromise = waitForMapKeyUpdate(root, 'key2'); + await entryPathObject.set('key2', 'value2'); + await keyUpdatedPromise; + + expect(eventCount).to.equal(1, 'Check only first event was received after unsubscribe'); + }, + }, + + { + description: 'PathObject.subscribe() handles multiple subscriptions independently', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + let subscription1Events = 0; + let subscription2Events = 0; + + const { unsubscribe: unsubscribe1 } = entryPathObject.subscribe(() => { + subscription1Events++; + }); + + entryPathObject.subscribe(() => { + subscription2Events++; + }); + + // Make change - both should receive event + let keyUpdatedPromise = waitForMapKeyUpdate(root, 'key1'); + await entryPathObject.set('key1', 'value1'); + await keyUpdatedPromise; + + // Unsubscribe first subscription + unsubscribe1(); + + // Make another change - only second should receive event + keyUpdatedPromise = waitForMapKeyUpdate(root, 'key2'); + await entryPathObject.set('key2', 'value2'); + await keyUpdatedPromise; + + expect(subscription1Events).to.equal(1, 'Check first subscription received one event'); + expect(subscription2Events).to.equal(2, 'Check second subscription received two events'); + }, + }, + + { + description: 'PathObject.subscribe() event object provides correct PathObject instance', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + const subscriptionPromise = new Promise((resolve, reject) => { + entryPathObject.subscribe((event) => { + try { + expect(event.object, 'Check event object exists').to.exist; + expectInstanceOf(event.object, 'DefaultPathObject', 'Check event object is PathObject instance'); + expect(event.object.path()).to.equal('', 'Check event object has correct path'); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + + const keyUpdatedPromise = waitForMapKeyUpdate(root, 'foo'); + await entryPathObject.set('foo', 'bar'); + await keyUpdatedPromise; + + await subscriptionPromise; + }, + }, + + { + description: 'PathObject.subscribe() handles subscription listener errors gracefully', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + let goodListenerCalled = false; + + // Add a listener that throws an error + entryPathObject.subscribe(() => { + throw new Error('Test subscription error'); + }); + + // Add a good listener to ensure other subscriptions still work + entryPathObject.subscribe(() => { + goodListenerCalled = true; + }); + + const keyUpdatedPromise = waitForMapKeyUpdate(root, 'foo'); + await entryPathObject.set('foo', 'bar'); + await keyUpdatedPromise; + + // Wait for next tick to ensure both listeners had a change to process the event + await new Promise((res) => nextTick(res)); + + expect(goodListenerCalled, 'Check good listener was called').to.be.true; + }, + }, + + { + description: 'PathObject.subscribe() throws error for invalid options', + action: async (ctx) => { + const { entryPathObject } = ctx; + + expect(() => { + entryPathObject.subscribe(() => {}, 'invalid'); + }).to.throw('Subscription options must be an object'); + + expect(() => { + entryPathObject.subscribe(() => {}, { depth: 0 }); + }).to.throw('Subscription depth must be greater than 0 or undefined for infinite depth'); + + expect(() => { + entryPathObject.subscribe(() => {}, { depth: -1 }); + }).to.throw('Subscription depth must be greater than 0 or undefined for infinite depth'); + }, + }, ]; const instanceScenarios = [ @@ -4841,6 +5369,247 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function withCode: 92007, }, ); + + // subscription mutation methods throw errors for non-LiveObjects + expect(() => { + primitiveInstance.subscribe(() => {}); + }) + .to.throw('Cannot subscribe to a non-LiveObject instance') + .with.property('code', 92007); + }, + }, + + { + description: 'DefaultInstance.subscribe() receives events for LiveMap set/remove operations', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + let keyUpdatedPromise = waitForMapKeyUpdate(root, 'map'); + await entryPathObject.set('map', LiveMap.create()); + await keyUpdatedPromise; + + const mapInstance = entryPathObject.get('map').instance(); + let eventCount = 0; + + const subscriptionPromise = new Promise((resolve, reject) => { + mapInstance.subscribe((event) => { + eventCount++; + + try { + expect(event.object).to.equal(mapInstance, 'Check event object is the same instance'); + expect(event.message, 'Check event message exists').to.exist; + + if (eventCount === 1) { + expect(event.message.operation.action).to.equal('map.set', 'Check first event is MAP_SET'); + } else if (eventCount === 2) { + expect(event.message.operation.action).to.equal('map.remove', 'Check second event is MAP_REMOVE'); + resolve(); + } + } catch (error) { + reject(error); + } + }); + }); + + keyUpdatedPromise = waitForMapKeyUpdate(root.get('map'), 'foo'); + await entryPathObject.get('map').set('foo', 'bar'); + await keyUpdatedPromise; + + keyUpdatedPromise = waitForMapKeyUpdate(root.get('map'), 'foo'); + await entryPathObject.get('map').remove('foo'); + await keyUpdatedPromise; + + await subscriptionPromise; + }, + }, + + { + description: 'DefaultInstance.subscribe() receives events for LiveCounter increment/decrement', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + let keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); + await entryPathObject.set('counter', LiveCounter.create()); + await keyUpdatedPromise; + + const counterInstance = entryPathObject.get('counter').instance(); + let eventCount = 0; + + const subscriptionPromise = new Promise((resolve, reject) => { + counterInstance.subscribe((event) => { + eventCount++; + + try { + expect(event.object).to.equal(counterInstance, 'Check event object is the same instance'); + expect(event.message, 'Check event message exists').to.exist; + + if (eventCount === 1) { + expect(event.message.operation.action).to.equal( + 'counter.inc', + 'Check first event is COUNTER_INC with positive value', + ); + expect(event.message.operation.counterOp.amount).to.equal( + 1, + 'Check first event is COUNTER_INC with positive value', + ); + } else if (eventCount === 2) { + expect(event.message.operation.action).to.equal( + 'counter.inc', + 'Check second event is COUNTER_INC with negative value', + ); + expect(event.message.operation.counterOp.amount).to.equal( + -1, + 'Check first event is COUNTER_INC with positive value', + ); + + resolve(); + } + } catch (error) { + reject(error); + } + }); + }); + + let counterUpdatedPromise = waitForCounterUpdate(root.get('counter')); + await entryPathObject.get('counter').increment(); + await counterUpdatedPromise; + + counterUpdatedPromise = waitForCounterUpdate(root.get('counter')); + await entryPathObject.get('counter').decrement(); + await counterUpdatedPromise; + + await subscriptionPromise; + }, + }, + + { + description: 'DefaultInstance.subscribe() returns "unsubscribe" function', + action: async (ctx) => { + const { entryPathObject } = ctx; + + const subscribeResponse = entryPathObject.instance().subscribe(() => {}); + + expect(subscribeResponse, 'Check subscribe response exists').to.exist; + expect(subscribeResponse.unsubscribe).to.be.a('function', 'Check unsubscribe is a function'); + + // Should not throw when called + subscribeResponse.unsubscribe(); + }, + }, + + { + description: 'can unsubscribe from DefaultInstance.subscribe() updates using returned "unsubscribe" function', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + let eventCount = 0; + const { unsubscribe } = entryPathObject.instance().subscribe(() => { + eventCount++; + }); + + // Make first change - should receive event + let keyUpdatedPromise = waitForMapKeyUpdate(root, 'key1'); + await entryPathObject.set('key1', 'value1'); + await keyUpdatedPromise; + + unsubscribe(); + + // Make second change - should NOT receive event + keyUpdatedPromise = waitForMapKeyUpdate(root, 'key2'); + await entryPathObject.set('key2', 'value2'); + await keyUpdatedPromise; + + expect(eventCount).to.equal(1, 'Check only first event was received after unsubscribe'); + }, + }, + + { + description: 'DefaultInstance.subscribe() handles multiple subscriptions independently', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + let subscription1Events = 0; + let subscription2Events = 0; + + const { unsubscribe: unsubscribe1 } = entryPathObject.instance().subscribe(() => { + subscription1Events++; + }); + + entryPathObject.instance().subscribe(() => { + subscription2Events++; + }); + + // Make change - both should receive event + let keyUpdatedPromise = waitForMapKeyUpdate(root, 'key1'); + await entryPathObject.set('key1', 'value1'); + await keyUpdatedPromise; + + // Unsubscribe first subscription + unsubscribe1(); + + // Make another change - only second should receive event + keyUpdatedPromise = waitForMapKeyUpdate(root, 'key2'); + await entryPathObject.set('key2', 'value2'); + await keyUpdatedPromise; + + expect(subscription1Events).to.equal(1, 'Check first subscription received one event'); + expect(subscription2Events).to.equal(2, 'Check second subscription received two events'); + }, + }, + + { + description: 'DefaultInstance.subscribe() event object provides correct DefaultInstance reference', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + const instance = entryPathObject.instance(); + const subscriptionPromise = new Promise((resolve, reject) => { + instance.subscribe((event) => { + try { + expect(event.object, 'Check event object exists').to.exist; + expectInstanceOf(event.object, 'DefaultInstance', 'Check event object is DefaultInstance'); + expect(event.object.id()).to.equal('root', 'Check event object has correct object ID'); + expect(event.object).to.equal(instance, 'Check event object is the same instance'); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + + const keyUpdatedPromise = waitForMapKeyUpdate(root, 'foo'); + await entryPathObject.set('foo', 'bar'); + await keyUpdatedPromise; + + await subscriptionPromise; + }, + }, + + { + description: 'DefaultInstance.subscribe() handles subscription listener errors gracefully', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + let goodListenerCalled = false; + + // Add a listener that throws an error + entryPathObject.instance().subscribe(() => { + throw new Error('Test subscription error'); + }); + + // Add a good listener to ensure other subscriptions still work + entryPathObject.instance().subscribe(() => { + goodListenerCalled = true; + }); + + const keyUpdatedPromise = waitForMapKeyUpdate(root, 'foo'); + await entryPathObject.set('foo', 'bar'); + await keyUpdatedPromise; + + // Wait for next tick to ensure both listeners had a change to process the event + await new Promise((res) => nextTick(res)); + + expect(goodListenerCalled, 'Check good listener was called').to.be.true; }, }, ]; From f9e0437bbf8aa487f5774b1c66c67cb633d47cf4 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Tue, 14 Oct 2025 09:19:29 +0100 Subject: [PATCH 05/45] Use stricter buffer types in the Objects plugin and web BufferUtils Stricter typing removes `ArrayBufferView` from the union type to align with the publicly exposed API, which only uses a union of `Buffer` and `ArrayBuffer`. This change enables the internal `ObjectMessage.operation` object to be exposed as the public `ObjectOperation` type in the following commit without buffer type mismatches for underlying values. --- src/platform/web/lib/util/bufferutils.ts | 2 +- src/plugins/objects/livemap.ts | 3 +-- src/plugins/objects/objectmessage.ts | 9 ++++----- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/platform/web/lib/util/bufferutils.ts b/src/platform/web/lib/util/bufferutils.ts index cccd538c78..9efa8f8b75 100644 --- a/src/platform/web/lib/util/bufferutils.ts +++ b/src/platform/web/lib/util/bufferutils.ts @@ -6,7 +6,7 @@ import { hmac as hmacSha256, sha256 } from './hmac-sha256'; * The exception is toBuffer, which returns a Uint8Array */ export type Bufferlike = BufferSource; -export type Output = Bufferlike; +export type Output = ArrayBuffer; export type ToBufferOutput = Uint8Array; class BufferUtils implements IBufferUtils { diff --git a/src/plugins/objects/livemap.ts b/src/plugins/objects/livemap.ts index efd08f3d0a..298973aa4c 100644 --- a/src/plugins/objects/livemap.ts +++ b/src/plugins/objects/livemap.ts @@ -1,6 +1,5 @@ import { dequal } from 'dequal'; -import type { Bufferlike } from 'common/platform'; import type * as API from '../../../ably'; import { LiveCounterValueType } from './livecountervaluetype'; import { LiveMapValueType } from './livemapvaluetype'; @@ -24,7 +23,7 @@ export interface ObjectIdObjectData { export interface ValueObjectData { /** A decoded leaf value from {@link WireObjectData}. */ - value: string | number | boolean | Bufferlike | API.JsonArray | API.JsonObject; + value: string | number | boolean | Buffer | ArrayBuffer | API.JsonArray | API.JsonObject; } export type LiveMapObjectData = ObjectIdObjectData | ValueObjectData; diff --git a/src/plugins/objects/objectmessage.ts b/src/plugins/objects/objectmessage.ts index ffcd14140a..a18644d116 100644 --- a/src/plugins/objects/objectmessage.ts +++ b/src/plugins/objects/objectmessage.ts @@ -2,7 +2,6 @@ import type BaseClient from 'common/lib/client/baseclient'; import type RealtimeChannel from 'common/lib/client/realtimechannel'; import type { MessageEncoding } from 'common/lib/types/basemessage'; import type * as Utils from 'common/lib/util/utils'; -import type { Bufferlike } from 'common/platform'; import type * as API from '../../../ably'; import type { JsonArray, JsonObject } from '../../../ably'; import { LiveObjectUpdate } from './liveobject'; @@ -24,7 +23,7 @@ export enum ObjectsMapSemantics { LWW = 0, } -export type PrimitiveObjectValue = string | number | boolean | Bufferlike | JsonArray | JsonObject; +export type PrimitiveObjectValue = string | number | boolean | Buffer | ArrayBuffer | JsonArray | JsonObject; /** * An ObjectData represents a value in an object on a channel decoded from {@link WireObjectData}. @@ -48,7 +47,7 @@ export interface WireObjectData { /** A primitive boolean leaf value in the object graph. Only one value field can be set. */ boolean?: boolean; // OD2c /** A primitive binary leaf value in the object graph. Only one value field can be set. Represented as a Base64-encoded string in JSON protocol */ - bytes?: Bufferlike | string; // OD2d + bytes?: Buffer | ArrayBuffer | string; // OD2d /** A primitive number leaf value in the object graph. Only one value field can be set. */ number?: number; // OD2e /** A primitive string leaf value in the object graph. Only one value field can be set. */ @@ -745,12 +744,12 @@ export class WireObjectMessage { format: Utils.Format | undefined, ): ObjectData { try { - let decodedBytes: Bufferlike | undefined; + let decodedBytes: Buffer | ArrayBuffer | undefined; if (objectData.bytes != null) { decodedBytes = format === 'msgpack' ? // OD5a1 - connection is using msgpack protocol, bytes are already a buffer - (objectData.bytes as Bufferlike) + (objectData.bytes as Buffer | ArrayBuffer) : // OD5b2 - connection is using JSON protocol, Base64-decode bytes value client.Platform.BufferUtils.base64Decode(String(objectData.bytes)); } From 9425fba0785f8a6e44d30b855f362af8ff61be15 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Tue, 14 Oct 2025 09:41:24 +0100 Subject: [PATCH 06/45] Expose full operation object in `ObjectMessage.operation` field for LiveObject subscriptions LiveObject subscriptions (via PathObject and Instance APIs) now expose the full operation object that caused the change in the `event.message.operation` field. --- ably.d.ts | 144 +++++++++++++++++- src/plugins/objects/instance.ts | 6 +- src/plugins/objects/liveobject.ts | 3 - src/plugins/objects/objectmessage.ts | 35 ++++- .../objects/pathobjectsubscriptionregister.ts | 6 +- src/plugins/objects/realtimeobject.ts | 8 +- 6 files changed, 179 insertions(+), 23 deletions(-) diff --git a/ably.d.ts b/ably.d.ts index 7b2089d7e8..20339661db 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -3133,6 +3133,62 @@ export type InstanceSubscriptionEvent = { message?: ObjectMessage; }; +/** + * The namespace containing the different types of object operation actions. + */ +declare namespace ObjectOperationActions { + /** + * Object operation action for a creating a map object. + */ + type MAP_CREATE = 'map.create'; + /** + * Object operation action for setting a key pair in a map object. + */ + type MAP_SET = 'map.set'; + /** + * Object operation action for removing a key from a map object. + */ + type MAP_REMOVE = 'map.remove'; + /** + * Object operation action for creating a counter object. + */ + type COUNTER_CREATE = 'counter.create'; + /** + * Object operation action for incrementing a counter object. + */ + type COUNTER_INC = 'counter.inc'; + /** + * Object operation action for deleting an object. + */ + type OBJECT_DELETE = 'object.delete'; +} + +/** + * The possible values of the `action` field of an {@link ObjectOperation}. + */ +export type ObjectOperationAction = + | ObjectOperationActions.MAP_CREATE + | ObjectOperationActions.MAP_SET + | ObjectOperationActions.MAP_REMOVE + | ObjectOperationActions.COUNTER_CREATE + | ObjectOperationActions.COUNTER_INC + | ObjectOperationActions.OBJECT_DELETE; + +/** + * The namespace containing the different types of map object semantics. + */ +declare namespace ObjectsMapSemanticsNamespace { + /** + * Last-write-wins conflict-resolution semantics. + */ + type LWW = 'lww'; +} + +/** + * The possible values of the `semantics` field of an {@link ObjectsMap}. + */ +export type ObjectsMapSemantics = ObjectsMapSemanticsNamespace.LWW; + /** * An object message that carried an operation. */ @@ -3157,6 +3213,10 @@ export interface ObjectMessage { * The name of the channel the object message was published to. */ channel: string; + /** + * Describes an operation that was applied to an object. + */ + operation: ObjectOperation; /** * An opaque string that uniquely identifies this object message. */ @@ -3179,10 +3239,90 @@ export interface ObjectMessage { headers?: Record; [key: string]: any; }; +} + +/** + * An operation that was applied to an object on a channel. + */ +export interface ObjectOperation { + /** The operation action, one of the {@link ObjectOperationAction} enum values. */ + action: ObjectOperationAction; + /** The ID of the object the operation was applied to. */ + objectId: string; + /** The payload for the operation if it is a mutation operation on a map object. */ + mapOp?: ObjectsMapOp; + /** The payload for the operation if it is a mutation operation on a counter object. */ + counterOp?: ObjectsCounterOp; + /** + * The payload for the operation if the action is {@link ObjectOperationActions.MAP_CREATE}. + * Defines the initial value of the map object. + */ + map?: ObjectsMap; /** - * The operation payload of the object message. + * The payload for the operation if the action is {@link ObjectOperationActions.COUNTER_CREATE}. + * Defines the initial value of the counter object. */ - payload: any; + counter?: ObjectsCounter; +} + +/** + * Describes an operation that was applied to a map object. + */ +export interface ObjectsMapOp { + /** The key that the operation was applied to. */ + key: string; + /** The data assigned to the key if the operation is {@link ObjectOperationActions.MAP_SET}. */ + data?: ObjectData; +} + +/** + * Describes an operation that was applied to a counter object. + */ +export interface ObjectsCounterOp { + /** The value added to the counter. */ + amount: number; +} + +/** + * Describes the initial value of a map object. + */ +export interface ObjectsMap { + /** The conflict-resolution semantics used by the map object, one of the {@link ObjectsMapSemantics} enum values. */ + semantics?: ObjectsMapSemantics; + /** The map entries, indexed by key. */ + entries?: Record; +} + +/** + * Describes a value at a specific key in a map object. + */ +export interface ObjectsMapEntry { + /** Indicates whether the map entry has been removed. */ + tombstone?: boolean; + /** The {@link ObjectMessage.serial} value of the last operation applied to the map entry. */ + timeserial?: string; + /** A timestamp derived from the {@link timeserial} field. Present only if {@link tombstone} is `true`. */ + serialTimestamp?: number; + /** The value associated with this map entry. */ + data?: ObjectData; +} + +/** + * Describes the initial value of a counter object. + */ +export interface ObjectsCounter { + /** The value of the counter. */ + count?: number; +} + +/** + * Represents a value in an object on a channel. + */ +export interface ObjectData { + /** A reference to another object. */ + objectId?: string; + /** A decoded primitive value. */ + value?: Primitive; } /** diff --git a/src/plugins/objects/instance.ts b/src/plugins/objects/instance.ts index 0a36199f9a..d433083208 100644 --- a/src/plugins/objects/instance.ts +++ b/src/plugins/objects/instance.ts @@ -2,15 +2,13 @@ import type BaseClient from 'common/lib/client/baseclient'; import type { AnyInstance, EventCallback, Instance, InstanceSubscriptionEvent, Primitive, Value } from '../../../ably'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; -import { LiveObject, LiveObjectUpdate, SubscribeResponse } from './liveobject'; +import { LiveObject, SubscribeResponse } from './liveobject'; import { ObjectMessage } from './objectmessage'; import { RealtimeObject } from './realtimeobject'; export interface InstanceEvent { /** Object message that caused this event */ message?: ObjectMessage; - /** Compact representation of an update to the object */ - update: Omit; } export class DefaultInstance implements AnyInstance { @@ -152,7 +150,7 @@ export class DefaultInstance implements AnyInstance { return this._value.instanceSubscribe((event: InstanceEvent) => { listener({ object: this as unknown as Instance, - message: event.message?.toUserFacingMessage(this._realtimeObject.getChannel(), event.update), + message: event.message?.toUserFacingMessage(this._realtimeObject.getChannel()), }); }); } diff --git a/src/plugins/objects/liveobject.ts b/src/plugins/objects/liveobject.ts index 61eda61240..5b9db44c49 100644 --- a/src/plugins/objects/liveobject.ts +++ b/src/plugins/objects/liveobject.ts @@ -358,7 +358,6 @@ export abstract class LiveObject< const event: InstanceEvent = { message: objectMessage, - update: this._updateWithoutType(update), }; this._instanceSubscriptions.emit(LiveObjectSubscriptionEvent.updated, event); } @@ -378,7 +377,6 @@ export abstract class LiveObject< const pathEvents: PathEvent[] = paths.map((path) => ({ path, message: objectMessage, - update: this._updateWithoutType(update), bubbles: true, })); @@ -392,7 +390,6 @@ export abstract class LiveObject< path: [...basePath, key], message: objectMessage, bubbles: false, - // Don't include update object as it may include updates for other keys }); } } diff --git a/src/plugins/objects/objectmessage.ts b/src/plugins/objects/objectmessage.ts index a18644d116..68b62b57ab 100644 --- a/src/plugins/objects/objectmessage.ts +++ b/src/plugins/objects/objectmessage.ts @@ -4,7 +4,17 @@ import type { MessageEncoding } from 'common/lib/types/basemessage'; import type * as Utils from 'common/lib/util/utils'; import type * as API from '../../../ably'; import type { JsonArray, JsonObject } from '../../../ably'; -import { LiveObjectUpdate } from './liveobject'; + +const operationActions: API.ObjectOperationAction[] = [ + 'map.create', + 'map.set', + 'map.remove', + 'counter.create', + 'counter.inc', + 'object.delete', +]; + +const mapSemantics: API.ObjectsMapSemantics[] = ['lww']; export type EncodeObjectDataFunction = (data: ObjectData | WireObjectData) => WireObjectData; @@ -72,7 +82,7 @@ export interface ObjectsMapOp { * @spec OCO1 */ export interface ObjectsCounterOp { - /** The data value that should be added to the counter */ + /** The data value that should be added to the counter. */ amount: number; // OCO2a } @@ -103,7 +113,7 @@ export interface ObjectsMapEntry { export interface ObjectsMap { /** The conflict-resolution semantics used by the map object. */ semantics?: ObjectsMapSemantics; // OMP3a - // The map entries, indexed by key. + /** The map entries, indexed by key. */ entries?: Record>; // OMP3b } @@ -329,6 +339,19 @@ function copyMsg( return result; } +function stringifyOperation(operation: ObjectOperation): API.ObjectOperation { + return { + ...operation, + action: operationActions[operation.action] || 'unknown', + map: operation.map + ? { + ...operation.map, + semantics: operation.map.semantics != null ? mapSemantics[operation.map.semantics] || 'unknown' : undefined, + } + : undefined, + }; +} + /** * A decoded {@link WireObjectMessage} message * @spec OM1 @@ -415,19 +438,19 @@ export class ObjectMessage { return strMsg(this, 'ObjectMessage'); } - toUserFacingMessage(channel: RealtimeChannel, update?: Omit): API.ObjectMessage { + toUserFacingMessage(channel: RealtimeChannel): API.ObjectMessage { return { id: this.id!, clientId: this.clientId, connectionId: this.connectionId, timestamp: this.timestamp!, channel: channel.name, + // we expose only operation messages to users, so operation field is always present + operation: stringifyOperation(this.operation!), serial: this.serial, serialTimestamp: this.serialTimestamp, siteCode: this.siteCode, extras: this.extras, - // TODO: provide REST API like type payload that describes the operation on an object - payload: update, }; } } diff --git a/src/plugins/objects/pathobjectsubscriptionregister.ts b/src/plugins/objects/pathobjectsubscriptionregister.ts index 12b7659bbe..16fc95676e 100644 --- a/src/plugins/objects/pathobjectsubscriptionregister.ts +++ b/src/plugins/objects/pathobjectsubscriptionregister.ts @@ -1,6 +1,6 @@ import type BaseClient from 'common/lib/client/baseclient'; import type { EventCallback, PathObjectSubscriptionEvent, PathObjectSubscriptionOptions } from '../../../ably'; -import { LiveObjectUpdate, SubscribeResponse } from './liveobject'; +import { SubscribeResponse } from './liveobject'; import { ObjectMessage } from './objectmessage'; import { DefaultPathObject } from './pathobject'; import { RealtimeObject } from './realtimeobject'; @@ -25,8 +25,6 @@ export interface PathEvent { path: string[]; /** Object message that caused this event */ message?: ObjectMessage; - /** Compact representation of an update to the object */ - update?: Omit; /** Whether this event should bubble up to parent paths. Defaults to true if not specified. */ bubbles?: boolean; } @@ -110,7 +108,7 @@ export class PathObjectSubscriptionRegister { try { const subscriptionEvent: PathObjectSubscriptionEvent = { object: new DefaultPathObject(this._realtimeObject, this._realtimeObject.getPool().getRoot(), event.path), - message: event.message?.toUserFacingMessage(this._realtimeObject.getChannel(), event.update), + message: event.message?.toUserFacingMessage(this._realtimeObject.getChannel()), }; subscription.listener(subscriptionEvent); diff --git a/src/plugins/objects/realtimeobject.ts b/src/plugins/objects/realtimeobject.ts index b8419c1dad..52cef43b09 100644 --- a/src/plugins/objects/realtimeobject.ts +++ b/src/plugins/objects/realtimeobject.ts @@ -347,7 +347,6 @@ export class RealtimeObject { const existingObjectUpdates: { object: LiveObject; update: LiveObjectUpdate | LiveObjectUpdateNoop; - message: ObjectMessage; }[] = []; // RTO5c1 @@ -360,7 +359,7 @@ export class RealtimeObject { const update = existingObject.overrideWithObjectState(entry.objectMessage); // RTO5c1a1 // store updates to call subscription callbacks for all of them once the sync sequence is completed. // this will ensure that clients get notified about the changes only once everything has been applied. - existingObjectUpdates.push({ object: existingObject, update, message: entry.objectMessage }); + existingObjectUpdates.push({ object: existingObject, update }); continue; } @@ -391,8 +390,9 @@ export class RealtimeObject { // This is necessary because objects may reference other objects that weren't in the pool when they were initially created this._rebuildAllParentReferences(); - // call subscription callbacks for all updated existing objects - existingObjectUpdates.forEach(({ object, update, message }) => object.notifyUpdated(update, message)); + // call subscription callbacks for all updated existing objects. + // do not expose the object message as part of the update, since this is an object sync message and does not represent a single operation. + existingObjectUpdates.forEach(({ object, update }) => object.notifyUpdated(update)); } private _applyObjectMessages(objectMessages: ObjectMessage[]): void { From c131f51d3b87914daffc442ed05ddc44d27ef655 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Wed, 22 Oct 2025 12:04:27 +0100 Subject: [PATCH 07/45] Change `WireObjectMessage` to not set undefined keys in decoded data --- src/plugins/objects/objectmessage.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/plugins/objects/objectmessage.ts b/src/plugins/objects/objectmessage.ts index 68b62b57ab..5481cc744d 100644 --- a/src/plugins/objects/objectmessage.ts +++ b/src/plugins/objects/objectmessage.ts @@ -767,6 +767,12 @@ export class WireObjectMessage { format: Utils.Format | undefined, ): ObjectData { try { + if (objectData.objectId != null) { + return { + objectId: objectData.objectId, + }; + } + let decodedBytes: Buffer | ArrayBuffer | undefined; if (objectData.bytes != null) { decodedBytes = @@ -783,7 +789,6 @@ export class WireObjectMessage { } return { - objectId: objectData.objectId, value: decodedBytes ?? decodedJson ?? objectData.boolean ?? objectData.number ?? objectData.string, }; } catch (error) { From 17f63246447fa9afb69c431b34ea2394a438fb8e Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Fri, 26 Sep 2025 08:46:31 +0100 Subject: [PATCH 08/45] Implement Instance API for LiveObjects PathObject Resolves PUB-2063 --- ably.d.ts | 270 ++++++++++++++-- scripts/moduleReport.ts | 1 + src/plugins/objects/instance.ts | 129 ++++++++ src/plugins/objects/pathobject.ts | 28 +- test/realtime/objects.test.js | 491 +++++++++++++++++++++++++++++- 5 files changed, 896 insertions(+), 23 deletions(-) create mode 100644 src/plugins/objects/instance.ts diff --git a/ably.d.ts b/ably.d.ts index 2196259fb5..89d6522b23 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2535,6 +2535,15 @@ export interface LiveMapPathObject = Record(key: K): PathObject; + + /** + * Get the specific map instance currently at this path. + * If the path does not resolve to any specific instance, returns `undefined`. + * + * @returns The {@link LiveMapInstance} at this path, or `undefined` if none exists. + * @experimental + */ + instance(): LiveMapInstance | undefined; } /** @@ -2548,6 +2557,15 @@ export interface LiveCounterPathObject extends PathObjectBase, Live * @experimental */ value(): number | undefined; + + /** + * Get the specific counter instance currently at this path. + * If the path does not resolve to any specific instance, returns `undefined`. + * + * @returns The {@link LiveCounterInstance} at this path, or `undefined` if none exists. + * @experimental + */ + instance(): LiveCounterInstance | undefined; } /** @@ -2638,12 +2656,23 @@ export interface AnyPathObject * @experimental */ value(): T | undefined; + + /** + * Get the specific object instance currently at this path. + * If the path does not resolve to any specific instance, returns `undefined`. + * + * @returns The object instance at this path, or `undefined` if none exists. + * @experimental + */ + instance(): AnyInstance | undefined; } /** * PathObject wraps a reference to a path starting from the entrypoint object on a channel. * The type parameter specifies the underlying type defined at that path, * and is used to infer the correct set of methods available for that type. + * + * @experimental */ export type PathObject = [T] extends [LiveMap] ? LiveMapPathObject @@ -2658,13 +2687,15 @@ export type PathObject = [T] extends [LiveMap] */ export interface LiveMapOperations = Record> { /** - * Sends an operation to the Ably system to set a key on a map at this path to a specified value. + * Sends an operation to the Ably system to set a key to a specified value on a given {@link LiveMapInstance}, + * or on the map instance resolved from the path when using {@link LiveMapPathObject}. * - * If the underlying map instance at the path cannot be resolved when invoked, this will throw an error. + * If called via {@link LiveMapPathObject} and the map instance at the specified path cannot be resolved at the time of the call, + * this method will throw an error. * * This does not modify the underlying data of the map. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. TODO: point to PathObject subscribe method + * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. TODO: point to PathObject/Instance subscribe method * * @param key - The key to set the value for. * @param value - The value to assign to the key. @@ -2674,13 +2705,15 @@ export interface LiveMapOperations = Record(key: K, value: T[K]): Promise; /** - * Sends an operation to the Ably system to remove a key from a map at this path. + * Sends an operation to the Ably system to remove a key from a given {@link LiveMapInstance}, + * or from the map instance resolved from the path when using {@link LiveMapPathObject}. * - * If the underlying map instance at the path cannot be resolved when invoked, this will throw an error. + * If called via {@link LiveMapPathObject} and the map instance at the specified path cannot be resolved at the time of the call, + * this method will throw an error. * * This does not modify the underlying data of the map. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. TODO: point to PathObject subscribe method + * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. TODO: point to PathObject/Instance subscribe method * * @param key - The key to remove. * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. @@ -2694,13 +2727,15 @@ export interface LiveMapOperations = Record = Record>(key: keyof T & string, value: T[keyof T]): Promise; /** - * Sends an operation to the Ably system to remove a key from a map at this path. + * Sends an operation to the Ably system to remove a key from the underlying map when using {@link AnyInstance}, + * or from the map instance resolved from the path when using {@link AnyPathObject}. * - * If the underlying map instance at the path cannot be resolved when invoked, this will throw an error. + * If called via {@link AnyPathObject} and the map instance at the specified path cannot be resolved at the time of the call, + * this method will throw an error. * * This does not modify the underlying data of the map. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. TODO: point to PathObject subscribe method + * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. TODO: point to PathObject/Instance subscribe method * * @param key - The key to remove. * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. @@ -2758,13 +2797,15 @@ export interface AnyOperations { // LiveCounter operations /** - * Sends an operation to the Ably system to increment the value of a counter at this path. + * Sends an operation to the Ably system to increment the value of the underlying counter when using {@link AnyInstance}, + * or of the counter instance resolved from the path when using {@link AnyPathObject}. * - * If the underlying counter instance at the path cannot be resolved when invoked, this will throw an error. + * If called via {@link AnyPathObject} and the counter instance at the specified path cannot be resolved at the time of the call, + * this method will throw an error. * * This does not modify the underlying data of the counter. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. TODO: point to PathObject subscribe method + * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. TODO: point to PathObject/Instance subscribe method * * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. @@ -2773,7 +2814,7 @@ export interface AnyOperations { increment(amount?: number): Promise; /** - * An alias for calling {@link LiveCounterOperations.increment | increment(-amount)} + * An alias for calling {@link AnyOperations.increment | increment(-amount)} * * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. @@ -2799,6 +2840,197 @@ export type LiveMapType = { [key: string]: PrimitiveObjectValue | LiveMapDeprecated | LiveCounterDeprecated | undefined; }; +/** + * InstanceBase defines the set of common methods on an Instance + * that are present regardless of the underlying type specified in the type parameter T. + */ +interface InstanceBase<_T extends Value> { + /** + * Get the object ID of this instance. + * + * @experimental + */ + id(): string; + + /** + * Get a JavaScript object representation of the instance. + * + * @experimental + */ + compact(): any; +} + +/** + * Defines collection methods available on a {@link LiveMapInstance}. + */ +interface LiveMapInstanceCollectionMethods = Record> { + /** + * Returns an iterable of key-value pairs for each entry in the map. + * Each value is represented as an {@link Instance} corresponding to its key. + * + * @experimental + */ + entries(): IterableIterator<[keyof T, Instance]>; + + /** + * Returns an iterable of keys in the map. + * + * @experimental + */ + keys(): IterableIterator; + + /** + * Returns an iterable of values in the map. + * Each value is represented as an {@link Instance}. + * + * @experimental + */ + values(): IterableIterator>; + + /** + * Returns the number of entries in the map. + * + * @experimental + */ + size(): number; +} + +/** + * LiveMapInstance represents an Instance of a LiveMap object. + * The type parameter T describes the expected structure of the map's entries. + */ +export interface LiveMapInstance = Record> + extends InstanceBase>, + LiveMapInstanceCollectionMethods, + LiveMapOperations { + /** + * Returns the value associated with a given key as an {@link Instance}. + * + * If the associated value is a primitive, returns a {@link PrimitiveInstance} + * that serves as a snapshot of the primitive value and does not reflect subsequent + * changes to the value at that key. + * + * Returns `undefined` if the key doesn't exist in the map, if the referenced {@link LiveObject} has been deleted, + * or if this map object itself has been deleted. + * + * @param key - The key to retrieve the value for. + * @returns An {@link Instance} representing a {@link LiveObject}, a primitive type (string, number, boolean, JSON-serializable object or array, or binary data) or `undefined` if the key doesn't exist in a map or the referenced {@link LiveObject} has been deleted. Always `undefined` if this map object is deleted. + * @experimental + */ + get(key: K): Instance | undefined; +} + +/** + * LiveCounterInstance represents an Instance of a LiveCounter object. + */ +export interface LiveCounterInstance extends InstanceBase, LiveCounterOperations { + /** + * Get the current value of the counter instance. + * + * @experimental + */ + value(): number; +} + +/** + * PrimitiveInstance represents a snapshot of a primitive value (string, number, boolean, JSON-serializable object or array, or binary data) + * that was stored at a key within a collection type. + */ +export interface PrimitiveInstance { + /** + * Get the primitive value represented by this instance. + * This reflects the value at the corresponding key in the collection at the time this instance was obtained. + * + * @experimental + */ + value(): T; +} + +/** + * AnyInstanceCollectionMethods defines all possible methods available on an Instance + * for the underlying collection types. + */ +interface AnyInstanceCollectionMethods { + // LiveMap collection methods + + /** + * Returns an iterable of key-value pairs for each entry in the map. + * Each value is represented as an {@link Instance} corresponding to its key. + * + * @experimental + */ + entries>(): IterableIterator<[keyof T, Instance]>; + + /** + * Returns an iterable of keys in the map. + * + * @experimental + */ + keys>(): IterableIterator; + + /** + * Returns an iterable of values in the map,. + * Each value is represented as a {@link Instance}. + * + * @experimental + */ + values>(): IterableIterator>; + + /** + * Returns the number of entries in the map. + * + * @experimental + */ + size(): number; +} + +/** + * Represents a AnyInstance when its underlying type is not known. + * Provides a unified interface that includes all possible methods. + * + * Each method supports type parameters to specify the expected + * underlying type when needed. + */ +export interface AnyInstance extends InstanceBase, AnyInstanceCollectionMethods, AnyOperations { + /** + * Navigate to a child entry within the collection by obtaining the {@link Instance} at that entry. + * The entry in a collection is identified with a string key. + * + * @param key - The key to get the child entry for. + * @returns The {@link Instance} for the specified key, or `undefined` if no such entry exists. + * @experimental + */ + get(key: string): Instance | undefined; + + /** + * Get the current value of the underlying LiveCounter or primitive. + * + * If the underlying value is a primitive, this reflects the value at the corresponding key + * in the collection at the time this instance was obtained. + * + * Returns `undefined` if the underlying object is of a different type. + * + * @returns The current value or `undefined` if not applicable to the underlying object. + * @experimental + */ + value(): T | undefined; +} + +/** + * Instance wraps a specific object instance or entry in a specific collection object instance. + * The type parameter specifies the underlying type of the instance, + * and is used to infer the correct set of methods available for that type. + * + * @experimental + */ +export type Instance = [T] extends [LiveMap] + ? LiveMapInstance + : [T] extends [LiveCounter] + ? LiveCounterInstance + : [T] extends [Primitive] + ? PrimitiveInstance + : AnyInstance; + /** * The default type for the entrypoint {@link LiveMapDeprecated} object on a channel, based on the globally defined {@link AblyObjectsTypes} interface. * diff --git a/scripts/moduleReport.ts b/scripts/moduleReport.ts index ebe8f7805b..b1243a369b 100644 --- a/scripts/moduleReport.ts +++ b/scripts/moduleReport.ts @@ -331,6 +331,7 @@ async function checkObjectsPluginFiles() { 'src/plugins/objects/batchcontextlivecounter.ts', 'src/plugins/objects/batchcontextlivemap.ts', 'src/plugins/objects/index.ts', + 'src/plugins/objects/instance.ts', 'src/plugins/objects/livecounter.ts', 'src/plugins/objects/livemap.ts', 'src/plugins/objects/liveobject.ts', diff --git a/src/plugins/objects/instance.ts b/src/plugins/objects/instance.ts new file mode 100644 index 0000000000..02ec9c6fd6 --- /dev/null +++ b/src/plugins/objects/instance.ts @@ -0,0 +1,129 @@ +import type BaseClient from 'common/lib/client/baseclient'; +import type { AnyInstance, Instance, Primitive, Value } from '../../../ably'; +import { LiveCounter } from './livecounter'; +import { LiveMap } from './livemap'; +import { LiveObject } from './liveobject'; +import { RealtimeObject } from './realtimeobject'; + +export class DefaultInstance implements AnyInstance { + protected _client: BaseClient; + + constructor( + private _realtimeObject: RealtimeObject, + private _value: T, + ) { + this._client = this._realtimeObject.getClient(); + } + + id(): string { + if (!(this._value instanceof LiveObject)) { + throw new this._client.ErrorInfo('Cannot get object ID for a non-LiveObject instance', 40000, 400); + } + return this._value.getObjectId(); + } + + compact(): any { + throw new Error('Not implemented'); + } + + get(key: string): Instance | undefined { + if (!(this._value instanceof LiveMap)) { + throw new this._client.ErrorInfo('Cannot get value for a key from a non-LiveMap instance', 40000, 400); + } + + if (typeof key !== 'string') { + throw new this._client.ErrorInfo(`Key must be a string: ${key}`, 40003, 400); + } + + const value = this._value.get(key); + if (value === undefined) { + return undefined; + } + return new DefaultInstance(this._realtimeObject, value) as unknown as Instance; + } + + value(): U | undefined { + if (this._value instanceof LiveObject) { + if (this._value instanceof LiveCounter) { + return this._value.value() as U; + } + + // for other LiveObject types, return undefined + return undefined; + } else if ( + this._client.Platform.BufferUtils.isBuffer(this._value) || + typeof this._value === 'string' || + typeof this._value === 'number' || + typeof this._value === 'boolean' || + typeof this._value === 'object' || + this._value === null + ) { + // primitive type - return it + return this._value as unknown as U; + } else { + // unknown type - return undefined + return undefined; + } + } + + *entries>(): IterableIterator<[keyof U, Instance]> { + if (!(this._value instanceof LiveMap)) { + throw new this._client.ErrorInfo('Cannot iterate entries on a non-LiveMap instance', 40000, 400); + } + + for (const [key, value] of this._value.entries()) { + const instance = new DefaultInstance(this._realtimeObject, value) as unknown as Instance; + yield [key, instance]; + } + } + + *keys>(): IterableIterator { + for (const [key] of this.entries()) { + yield key; + } + } + + *values>(): IterableIterator> { + for (const [_, value] of this.entries()) { + yield value; + } + } + + size(): number { + if (!(this._value instanceof LiveMap)) { + throw new this._client.ErrorInfo('Cannot get size of a non-LiveMap instance', 40000, 400); + } + return this._value.size(); + } + + set = Record>( + key: keyof U & string, + value: U[keyof U], + ): Promise { + if (!(this._value instanceof LiveMap)) { + throw new this._client.ErrorInfo('Cannot set a key on a non-LiveMap instance', 40000, 400); + } + return this._value.set(key, value); + } + + remove = Record>(key: keyof U & string): Promise { + if (!(this._value instanceof LiveMap)) { + throw new this._client.ErrorInfo('Cannot remove a key from a non-LiveMap instance', 40000, 400); + } + return this._value.remove(key); + } + + increment(amount?: number | undefined): Promise { + if (!(this._value instanceof LiveCounter)) { + throw new this._client.ErrorInfo('Cannot increment a non-LiveCounter instance', 40000, 400); + } + return this._value.increment(amount ?? 1); + } + + decrement(amount?: number | undefined): Promise { + if (!(this._value instanceof LiveCounter)) { + throw new this._client.ErrorInfo('Cannot decrement a non-LiveCounter instance', 40000, 400); + } + return this._value.decrement(amount ?? 1); + } +} diff --git a/src/plugins/objects/pathobject.ts b/src/plugins/objects/pathobject.ts index e86fdc253b..a5d49b8804 100644 --- a/src/plugins/objects/pathobject.ts +++ b/src/plugins/objects/pathobject.ts @@ -1,6 +1,7 @@ import type BaseClient from 'common/lib/client/baseclient'; import type * as API from '../../../ably'; -import type { AnyPathObject, PathObject, Primitive, Value } from '../../../ably'; +import type { AnyInstance, AnyPathObject, PathObject, Primitive, Value } from '../../../ably'; +import { DefaultInstance } from './instance'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; import { LiveObject } from './liveobject'; @@ -144,6 +145,27 @@ export class DefaultPathObject implements AnyPathObject } } + instance(): AnyInstance | undefined { + try { + const value = this._resolvePath(this._path); + + if (value instanceof LiveObject) { + // only return an Instance for LiveObject values + return new DefaultInstance(this._realtimeObject, value); + } + + // return undefined for primitive values + return undefined; + } catch (error) { + if (this._client.Utils.isErrorInfoOrPartialErrorInfo(error)) { + // ignore ErrorInfos indicating path resolution failure and return undefined + return undefined; + } + // otherwise rethrow unexpected errors + throw error; + } + } + /** * Returns an iterator of [key, value] pairs for LiveMap entries */ @@ -211,7 +233,7 @@ export class DefaultPathObject implements AnyPathObject } } - set = Record>( + set = Record>( key: keyof T & string, value: T[keyof T], ): Promise { @@ -227,7 +249,7 @@ export class DefaultPathObject implements AnyPathObject return resolved.set(key, value); } - remove = Record>(key: keyof T & string): Promise { + remove = Record>(key: keyof T & string): Promise { const resolved = this._resolvePath(this._path); if (!(resolved instanceof LiveMap)) { throw new this._client.ErrorInfo( diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js index 38217efffc..c344d2282a 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/objects.test.js @@ -460,11 +460,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function if (keyData.data.bytes != null) { helper.recordPrivateApi('call.BufferUtils.base64Decode'); helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); - expect( BufferUtils.areBuffersEqual(pathObject.get(key).value(), BufferUtils.base64Decode(keyData.data.bytes)), msg, ).to.be.true; + + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); expect(BufferUtils.areBuffersEqual(pathObject.get(key).value(), mapObj.get(key)), compareMsg).to.be.true; } else if (keyData.data.json != null) { const expectedObject = JSON.parse(keyData.data.json); @@ -477,6 +479,26 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function } } + function checkKeyDataOnInstance({ helper, key, keyData, instance, msg }) { + const entryInstance = instance.get(key); + + expect(entryInstance, `Check instance exists for "${keyData.key}"`).to.exist; + expectInstanceOf(entryInstance, 'DefaultInstance', `Check instance for "${keyData.key}" is DefaultInstance`); + + if (keyData.data.bytes != null) { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); + expect(BufferUtils.areBuffersEqual(entryInstance.value(), BufferUtils.base64Decode(keyData.data.bytes)), msg) + .to.be.true; + } else if (keyData.data.json != null) { + const expectedObject = JSON.parse(keyData.data.json); + expect(entryInstance.value()).to.deep.equal(expectedObject, msg); + } else { + const expectedValue = keyData.data.string ?? keyData.data.number ?? keyData.data.boolean; + expect(entryInstance.value()).to.equal(expectedValue, msg); + } + } + const primitiveKeyData = [ { key: 'stringKey', data: { string: 'stringValue' } }, { key: 'emptyStringKey', data: { string: '' } }, @@ -4562,6 +4584,472 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); }, }, + + { + description: 'PathObject.instance() returns DefaultInstance for LiveMap and LiveCounter', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject } = ctx; + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(root, 'map'), + waitForMapKeyUpdate(root, 'counter'), + ]); + await root.set('map', await realtimeObject.createMap()); + await root.set('counter', await realtimeObject.createCounter()); + await keysUpdatedPromise; + + const counterInstance = entryPathObject.get('counter').instance(); + expect(counterInstance, 'Check instance exists for counter path').to.exist; + expectInstanceOf(counterInstance, 'DefaultInstance', 'Check counter instance is DefaultInstance'); + + const mapInstance = entryPathObject.get('map').instance(); + expect(mapInstance, 'Check instance exists for map path').to.exist; + expectInstanceOf(mapInstance, 'DefaultInstance', 'Check map instance is DefaultInstance'); + }, + }, + + { + description: 'PathObject.instance() returns undefined for primitive values and non-existent paths', + action: async (ctx) => { + const { root, helper, entryPathObject } = ctx; + + const keysUpdatedPromise = Promise.all(primitiveKeyData.map((x) => waitForMapKeyUpdate(root, x.key))); + await Promise.all( + primitiveKeyData.map(async (keyData) => { + let value; + if (keyData.data.bytes != null) { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + value = BufferUtils.base64Decode(keyData.data.bytes); + } else if (keyData.data.json != null) { + value = JSON.parse(keyData.data.json); + } else { + value = keyData.data.number ?? keyData.data.string ?? keyData.data.boolean; + } + + await entryPathObject.set(keyData.key, value); + }), + ); + await keysUpdatedPromise; + + primitiveKeyData.forEach((keyData) => { + const primitiveInstance = entryPathObject.get(keyData.key).instance(); + expect(primitiveInstance, 'Check instance is undefined for primitive path').to.be.undefined; + }); + + expect(entryPathObject.at('non.existing.path').instance(), 'Check instance is undefined for primitive path') + .to.be.undefined; + }, + }, + ]; + + const instanceScenarios = [ + { + description: 'DefaultInstance.id() returns object ID of underlying LiveObject', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject, helper } = ctx; + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(root, 'map'), + waitForMapKeyUpdate(root, 'counter'), + ]); + const map = await realtimeObject.createMap(); + const counter = await realtimeObject.createCounter(); + await entryPathObject.set('map', map); + await entryPathObject.set('counter', counter); + await keysUpdatedPromise; + + const mapInstance = entryPathObject.get('map').instance(); + const counterInstance = entryPathObject.get('counter').instance(); + + helper.recordPrivateApi('call.LiveObject.getObjectId'); + expect(mapInstance.id()).to.equal(map.getObjectId(), 'Check map instance ID matches underlying LiveMap ID'); + + helper.recordPrivateApi('call.LiveObject.getObjectId'); + expect(counterInstance.id()).to.equal( + counter.getObjectId(), + 'Check counter instance ID matches underlying LiveCounter ID', + ); + }, + }, + + { + description: 'DefaultInstance.get() returns child DefaultInstance instances', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject } = ctx; + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(root, 'stringKey'), + waitForMapKeyUpdate(root, 'counterKey'), + ]); + await entryPathObject.set('stringKey', 'value'); + await entryPathObject.set('counterKey', await realtimeObject.createCounter(42)); + await keysUpdatedPromise; + + const rootInstance = entryPathObject.instance(); + + const stringInstance = rootInstance.get('stringKey'); + expect(stringInstance, 'Check string DefaultInstance exists').to.exist; + expectInstanceOf(stringInstance, 'DefaultInstance', 'string instance should be of DefaultInstance type'); + expect(stringInstance.value()).to.equal('value', 'Check string instance has correct value'); + + const counterInstance = rootInstance.get('counterKey'); + expect(counterInstance, 'Check counter DefaultInstance exists').to.exist; + expectInstanceOf(counterInstance, 'DefaultInstance', 'counter instance should be of DefaultInstance type'); + expect(counterInstance.value()).to.equal(42, 'Check counter instance has correct value'); + }, + }, + + { + description: 'DefaultInstance.get() throws error for non-string keys', + action: async (ctx) => { + const { entryPathObject } = ctx; + + const rootInstance = entryPathObject.instance(); + + expect(() => rootInstance.get()).to.throw('Key must be a string'); + expect(() => rootInstance.get(null)).to.throw('Key must be a string'); + expect(() => rootInstance.get(123)).to.throw('Key must be a string'); + expect(() => rootInstance.get(BigInt(1))).to.throw('Key must be a string'); + expect(() => rootInstance.get(true)).to.throw('Key must be a string'); + expect(() => rootInstance.get({})).to.throw('Key must be a string'); + expect(() => rootInstance.get([])).to.throw('Key must be a string'); + }, + }, + + { + description: 'DefaultInstance.value() returns primitive values correctly', + action: async (ctx) => { + const { root, entryPathObject, helper } = ctx; + + const keysUpdatedPromise = Promise.all(primitiveKeyData.map((x) => waitForMapKeyUpdate(root, x.key))); + await Promise.all( + primitiveKeyData.map(async (keyData) => { + let value; + if (keyData.data.bytes != null) { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + value = BufferUtils.base64Decode(keyData.data.bytes); + } else if (keyData.data.json != null) { + value = JSON.parse(keyData.data.json); + } else { + value = keyData.data.number ?? keyData.data.string ?? keyData.data.boolean; + } + + await entryPathObject.set(keyData.key, value); + }), + ); + await keysUpdatedPromise; + + const rootInstance = entryPathObject.instance(); + + primitiveKeyData.forEach((keyData) => { + checkKeyDataOnInstance({ + helper, + key: keyData.key, + keyData, + instance: rootInstance, + msg: `Check DefaultInstance returns correct value for "${keyData.key}" key after PathObject.set call`, + }); + }); + }, + }, + + { + description: 'DefaultInstance.value() returns LiveCounter values', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); + const counter = await realtimeObject.createCounter(10); + await entryPathObject.set('counter', counter); + await keyUpdatedPromise; + + const counterInstance = entryPathObject.get('counter').instance(); + + expect(counterInstance.value()).to.equal(10, 'Check counter value is returned correctly'); + }, + }, + + { + description: 'DefaultInstance.value() returns undefined for LiveMap objects', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(root, 'map'); + const map = await realtimeObject.createMap({ key: 'map' }); + await entryPathObject.set('map', map); + await keyUpdatedPromise; + + const mapInstance = entryPathObject.get('map').instance(); + + expect(mapInstance.value(), 'Check DefaultInstance.value() for a LiveMap object returns undefined').to.be + .undefined; + }, + }, + + { + description: 'DefaultInstance collection methods work for LiveMap objects', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + // Set up test data + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(root, 'key1'), + waitForMapKeyUpdate(root, 'key2'), + waitForMapKeyUpdate(root, 'key3'), + ]); + await entryPathObject.set('key1', 'value1'); + await entryPathObject.set('key2', 'value2'); + await entryPathObject.set('key3', 'value3'); + await keysUpdatedPromise; + + const rootInstance = entryPathObject.instance(); + + // Test size + expect(rootInstance.size()).to.equal(3, 'Check DefaultInstance size'); + + // Test keys + const keys = [...rootInstance.keys()]; + expect(keys).to.have.members(['key1', 'key2', 'key3'], 'Check DefaultInstance keys'); + + // Test entries + const entries = [...rootInstance.entries()]; + expect(entries).to.have.lengthOf(3, 'Check DefaultInstance entries length'); + + const entryKeys = entries.map(([key]) => key); + expect(entryKeys).to.have.members(['key1', 'key2', 'key3'], 'Check entry keys'); + + const entryValues = entries.map(([key, instance]) => instance.value()); + expect(entryValues).to.have.members(['value1', 'value2', 'value3'], 'Check DefaultInstance entries values'); + + // Test values + const values = [...rootInstance.values()]; + expect(values).to.have.lengthOf(3, 'Check DefaultInstance values length'); + + const valueValues = values.map((instance) => instance.value()); + expect(valueValues).to.have.members(['value1', 'value2', 'value3'], 'Check DefaultInstance values'); + }, + }, + + { + description: 'DefaultInstance collection methods throw errors for non-LiveMap objects', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(root, 'primitive'); + await entryPathObject.set('primitive', 'value'); + await keyUpdatedPromise; + + const rootInstance = entryPathObject.instance(); + const primitiveInstance = rootInstance.get('primitive'); + + expect(() => primitiveInstance.size()).to.throw('Cannot get size of a non-LiveMap instance'); + expect(() => [...primitiveInstance.entries()]).to.throw('Cannot iterate entries on a non-LiveMap instance'); + expect(() => [...primitiveInstance.values()]).to.throw('Cannot iterate entries on a non-LiveMap instance'); + expect(() => [...primitiveInstance.keys()]).to.throw('Cannot iterate entries on a non-LiveMap instance'); + }, + }, + + { + description: 'DefaultInstance.set() works for LiveMap objects with primitive values', + action: async (ctx) => { + const { root, entryPathObject, helper } = ctx; + + const rootInstance = entryPathObject.instance(); + + const keysUpdatedPromise = Promise.all(primitiveKeyData.map((x) => waitForMapKeyUpdate(root, x.key))); + await Promise.all( + primitiveKeyData.map(async (keyData) => { + let value; + if (keyData.data.bytes != null) { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + value = BufferUtils.base64Decode(keyData.data.bytes); + } else if (keyData.data.json != null) { + value = JSON.parse(keyData.data.json); + } else { + value = keyData.data.number ?? keyData.data.string ?? keyData.data.boolean; + } + + await rootInstance.set(keyData.key, value); + }), + ); + await keysUpdatedPromise; + + // check primitive values were set correctly via Instance + primitiveKeyData.forEach((keyData) => { + checkKeyDataOnInstance({ + helper, + key: keyData.key, + keyData, + instance: rootInstance, + msg: `Check DefaultInstance returns correct value for "${keyData.key}" key after DefaultInstance.set call`, + }); + }); + }, + }, + + { + description: 'DefaultInstance.set() works for LiveMap objects with LiveObject references', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject } = ctx; + + const rootInstance = entryPathObject.instance(); + + const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counterKey'); + const counter = await realtimeObject.createCounter(5); + await rootInstance.set('counterKey', counter); + await keyUpdatedPromise; + + expect(root.get('counterKey')).to.equal(counter, 'Check counter object was set via DefaultInstance'); + expect(rootInstance.get('counterKey').value()).to.equal(5, 'Check DefaultInstance reflects counter value'); + }, + }, + + { + description: 'DefaultInstance.remove() works for LiveMap objects', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + const rootInstance = entryPathObject.instance(); + + const keyAddedPromise = waitForMapKeyUpdate(root, 'keyToRemove'); + await entryPathObject.set('keyToRemove', 'valueToRemove'); + await keyAddedPromise; + + expect(entryPathObject.get('keyToRemove').value(), 'Check key exists on root').to.exist; + + const keyRemovedPromise = waitForMapKeyUpdate(root, 'keyToRemove'); + await rootInstance.remove('keyToRemove'); + await keyRemovedPromise; + + expect(root.get('keyToRemove'), 'Check key on root is removed after DefaultInstance.remove()').to.be + .undefined; + expect( + rootInstance.get('keyToRemove'), + 'Check value for instance is undefined after DefaultInstance.remove()', + ).to.be.undefined; + }, + }, + + { + description: 'DefaultInstance map mutation methods throw errors for non-LiveMap objects', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject } = ctx; + + const rootInstance = entryPathObject.instance(); + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(root, 'counter'), + waitForMapKeyUpdate(root, 'primitive'), + ]); + const counter = await realtimeObject.createCounter(5); + await entryPathObject.set('counter', counter); + await entryPathObject.set('primitive', 'value'); + await keysUpdatedPromise; + + const primitiveInstance = rootInstance.get('primitive'); + await expectToThrowAsync( + async () => primitiveInstance.set('key', 'value'), + 'Cannot set a key on a non-LiveMap instance', + ); + await expectToThrowAsync( + async () => primitiveInstance.remove('key'), + 'Cannot remove a key from a non-LiveMap instance', + ); + + const counterInstance = rootInstance.get('counter'); + await expectToThrowAsync( + async () => counterInstance.set('key', 'value'), + 'Cannot set a key on a non-LiveMap instance', + ); + await expectToThrowAsync( + async () => counterInstance.remove('key'), + 'Cannot remove a key from a non-LiveMap instance', + ); + }, + }, + + { + description: 'DefaultInstance.increment() and DefaultInstance.decrement() work for LiveCounter objects', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject } = ctx; + + const rootInstance = entryPathObject.instance(); + + const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); + const counter = await realtimeObject.createCounter(10); + await entryPathObject.set('counter', counter); + await keyUpdatedPromise; + + const counterInstance = rootInstance.get('counter'); + + let counterUpdatedPromise = waitForCounterUpdate(counter); + await counterInstance.increment(5); + await counterUpdatedPromise; + + expect(counter.value()).to.equal(15, 'Check counter incremented via DefaultInstance'); + expect(counterInstance.value()).to.equal(15, 'Check DefaultInstance reflects incremented value'); + + counterUpdatedPromise = waitForCounterUpdate(counter); + await counterInstance.decrement(3); + await counterUpdatedPromise; + + expect(counter.value()).to.equal(12, 'Check counter decremented via DefaultInstance'); + expect(counterInstance.value()).to.equal(12, 'Check DefaultInstance reflects decremented value'); + + // test increment/decrement without argument (should increment/decrement by 1) + counterUpdatedPromise = waitForCounterUpdate(counter); + await counterInstance.increment(); + await counterUpdatedPromise; + + expect(counter.value()).to.equal(13, 'Check counter incremented via DefaultInstance without argument'); + expect(counterInstance.value()).to.equal(13, 'Check DefaultInstance reflects incremented value'); + + counterUpdatedPromise = waitForCounterUpdate(counter); + await counterInstance.decrement(); + await counterUpdatedPromise; + + expect(counter.value()).to.equal(12, 'Check counter decremented via DefaultInstance without argument'); + expect(counterInstance.value()).to.equal(12, 'Check DefaultInstance reflects decremented value'); + }, + }, + + { + description: 'DefaultInstance counter methods throw errors for non-LiveCounter objects', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject } = ctx; + + const rootInstance = entryPathObject.instance(); + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(root, 'map'), + waitForMapKeyUpdate(root, 'primitive'), + ]); + const map = await realtimeObject.createMap(); + await entryPathObject.set('map', map); + await entryPathObject.set('primitive', 'value'); + await keysUpdatedPromise; + + const primitiveInstance = rootInstance.get('primitive'); + await expectToThrowAsync( + async () => primitiveInstance.increment(), + 'Cannot increment a non-LiveCounter instance', + ); + await expectToThrowAsync( + async () => primitiveInstance.decrement(), + 'Cannot decrement a non-LiveCounter instance', + ); + + const mapInstance = rootInstance.get('map'); + await expectToThrowAsync( + async () => mapInstance.increment(), + 'Cannot increment a non-LiveCounter instance', + ); + await expectToThrowAsync( + async () => mapInstance.decrement(), + 'Cannot decrement a non-LiveCounter instance', + ); + }, + }, ]; /** @nospec */ @@ -4574,6 +5062,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ...writeApiScenarios, ...liveMapEnumerationScenarios, ...pathObjectScenarios, + ...instanceScenarios, ], async function (helper, scenario, clientOptions, channelName) { const objectsHelper = new ObjectsHelper(helper); From 489603f664197b10bb072496c1340c5f00fd89cb Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Wed, 17 Sep 2025 05:15:26 +0100 Subject: [PATCH 09/45] Implement LiveMap and LiveCounter creation via value types Exposes LiveMap.create() and LiveCounter.create() static methods at ably/objects export. Updates all objects tests to use these new methods to create LiveMap and LiveCounter instead of realtimeObject.createMap()/.createCounter() calls Resolves PUB-2060 --- ably.d.ts | 4 +- objects.d.ts | 40 +- scripts/moduleReport.ts | 2 + src/plugins/objects/index.ts | 12 +- src/plugins/objects/livecountervaluetype.ts | 49 ++ src/plugins/objects/livemap.ts | 75 ++- src/plugins/objects/livemapvaluetype.ts | 169 ++++++ test/realtime/objects.test.js | 585 +++++++------------- 8 files changed, 552 insertions(+), 384 deletions(-) create mode 100644 src/plugins/objects/livecountervaluetype.ts create mode 100644 src/plugins/objects/livemapvaluetype.ts diff --git a/ably.d.ts b/ably.d.ts index 2a17d5bb51..ac5fb41034 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2400,10 +2400,10 @@ export type Primitive = * Unique symbol for nominal typing within TypeScript's structural type system. * This prevents structural compatibility between LiveObject types. */ -declare const __livetype: unique symbol; +export declare const __livetype: unique symbol; // Branded interfaces that enables TypeScript to distinguish -// between LiveObject types even when they have identical structure. +// between LiveObject types even when they have identical structure (empty interfaces in this case). // Enables PathObject to dispatch to correct method sets via conditional types. /** * A {@link LiveMap} is a collection type that maps string keys to values, which can be either primitive values or other LiveObjects. diff --git a/objects.d.ts b/objects.d.ts index 6c89bfc3c0..04044f4210 100644 --- a/objects.d.ts +++ b/objects.d.ts @@ -1,9 +1,47 @@ // The ESLint warning is triggered because we only use these types in a documentation comment. /* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */ -import { RealtimeClient } from './ably'; +import { + LiveCounter as LiveCounterType, + LiveMap as LiveMapType, + LiveMapOperations, + RealtimeClient, + Value, +} from './ably'; import { BaseRealtime } from './modular'; /* eslint-enable no-unused-vars, @typescript-eslint/no-unused-vars */ +/** + * Static utilities related to LiveMap instances. + */ +export class LiveMap { + /** + * Creates a {@link LiveMapType | LiveMap} value type that can be passed to mutation methods + * (such as {@link LiveMapOperations.set}) to assign a new LiveMap to the channel object. + * + * @param initialEntries - Optional initial entries for the new LiveMap object. + * @returns A {@link LiveMapType | LiveMap} value type representing the initial state of the new LiveMap. + * @experimental + */ + static create>( + initialEntries?: T, + ): LiveMapType ? T : {}>; +} + +/** + * Static utilities related to LiveCounter instances. + */ +export class LiveCounter { + /** + * Creates a {@link LiveCounterType | LiveCounter} value type that can be passed to mutation methods + * (such as {@link LiveMapOperations.set}) to assign a new LiveCounter to the channel object. + * + * @param initialCount - Optional initial count for the new LiveCounter object. + * @returns A {@link LiveCounterType | LiveCounter} value type representing the initial state of the new LiveCounter. + * @experimental + */ + static create(initialCount?: number): LiveCounterType; +} + /** * Provides a {@link RealtimeClient} instance with the ability to use Objects functionality. * diff --git a/scripts/moduleReport.ts b/scripts/moduleReport.ts index b1243a369b..aed831715c 100644 --- a/scripts/moduleReport.ts +++ b/scripts/moduleReport.ts @@ -333,7 +333,9 @@ async function checkObjectsPluginFiles() { 'src/plugins/objects/index.ts', 'src/plugins/objects/instance.ts', 'src/plugins/objects/livecounter.ts', + 'src/plugins/objects/livecountervaluetype.ts', 'src/plugins/objects/livemap.ts', + 'src/plugins/objects/livemapvaluetype.ts', 'src/plugins/objects/liveobject.ts', 'src/plugins/objects/objectid.ts', 'src/plugins/objects/objectmessage.ts', diff --git a/src/plugins/objects/index.ts b/src/plugins/objects/index.ts index bac74ad34d..ddf25c625b 100644 --- a/src/plugins/objects/index.ts +++ b/src/plugins/objects/index.ts @@ -1,9 +1,19 @@ +import { LiveCounterValueType } from './livecountervaluetype'; +import { LiveMapValueType } from './livemapvaluetype'; import { ObjectMessage, WireObjectMessage } from './objectmessage'; import { RealtimeObject } from './realtimeobject'; -export { ObjectMessage, RealtimeObject, WireObjectMessage }; +export { + LiveCounterValueType as LiveCounter, + LiveMapValueType as LiveMap, + ObjectMessage, + RealtimeObject, + WireObjectMessage, +}; export default { + LiveCounter: LiveCounterValueType, + LiveMap: LiveMapValueType, ObjectMessage, RealtimeObject, WireObjectMessage, diff --git a/src/plugins/objects/livecountervaluetype.ts b/src/plugins/objects/livecountervaluetype.ts new file mode 100644 index 0000000000..ecf27b73a2 --- /dev/null +++ b/src/plugins/objects/livecountervaluetype.ts @@ -0,0 +1,49 @@ +import type * as API from '../../../ably'; +import { LiveCounter } from './livecounter'; +import { ObjectMessage } from './objectmessage'; +import { RealtimeObject } from './realtimeobject'; + +/** + * A value type class that serves as a simple container for LiveCounter data. + * Contains sufficient information for the client to produce a COUNTER_CREATE operation + * for the LiveCounter object. + * + * Properties of this class are immutable after construction and the instance + * will be frozen to prevent mutation. + */ +export class LiveCounterValueType implements API.LiveCounter { + declare readonly [API.__livetype]: 'LiveCounter'; // type-only, unique symbol to satisfy branded interfaces, no JS emitted + private readonly _livetype = 'LiveCounter'; // use a runtime property to provide a reliable cross-bundle type identification instead of `instanceof` operator + private readonly _count: number; + + private constructor(count: number) { + this._count = count; + Object.freeze(this); + } + + static create(initialCount: number = 0): API.LiveCounter { + // We can't directly import the ErrorInfo class from the core library into the plugin (as this would bloat the plugin size), + // and, since we're in a user-facing static method, we can't expect a user to pass a client library instance, as this would make the API ugly. + // Since we can't use ErrorInfo here, we won't do any validation at this step; instead, validation will happen in the mutation methods + // when we try to create this object. + + return new LiveCounterValueType(initialCount); + } + + /** + * @internal + */ + static instanceof(value: unknown): value is LiveCounterValueType { + return typeof value === 'object' && value !== null && (value as LiveCounterValueType)._livetype === 'LiveCounter'; + } + + /** + * @internal + */ + static createCounterCreateMessage( + realtimeObject: RealtimeObject, + value: LiveCounterValueType, + ): Promise { + return LiveCounter.createCounterCreateMessage(realtimeObject, value._count); + } +} diff --git a/src/plugins/objects/livemap.ts b/src/plugins/objects/livemap.ts index b663fa28a3..7032cb9e83 100644 --- a/src/plugins/objects/livemap.ts +++ b/src/plugins/objects/livemap.ts @@ -2,6 +2,8 @@ import { dequal } from 'dequal'; import type { Bufferlike } from 'common/platform'; import type * as API from '../../../ably'; +import { LiveCounterValueType } from './livecountervaluetype'; +import { LiveMapValueType } from './livemapvaluetype'; import { LiveObject, LiveObjectData, LiveObjectUpdate, LiveObjectUpdateNoop } from './liveobject'; import { ObjectId } from './objectid'; import { @@ -138,6 +140,61 @@ export class LiveMap extends LiveObject( + realtimeObject: RealtimeObject, + objectId: string, + key: TKey, + value: LiveCounterValueType | LiveMapValueType, + ): Promise { + const client = realtimeObject.getClient(); + + LiveMap.validateKeyValue(realtimeObject, key, value); + + let objectData: LiveMapObjectData; + let createValueTypesMessages: ObjectMessage[] = []; + if (LiveCounterValueType.instanceof(value)) { + const counterCreateMsg = await LiveCounterValueType.createCounterCreateMessage(realtimeObject, value); + createValueTypesMessages = [counterCreateMsg]; + + const typedObjectData: ObjectIdObjectData = { objectId: counterCreateMsg.operation?.objectId! }; + objectData = typedObjectData; + } else { + const { mapCreateMsg, nestedObjectsCreateMsgs } = await LiveMapValueType.createMapCreateMessage( + realtimeObject, + value, + ); + createValueTypesMessages = [...nestedObjectsCreateMsgs, mapCreateMsg]; + + const typedObjectData: ObjectIdObjectData = { objectId: mapCreateMsg.operation?.objectId! }; + objectData = typedObjectData; + } + + const mapSetMsg = ObjectMessage.fromValues( + { + operation: { + action: ObjectOperationAction.MAP_SET, + objectId, + mapOp: { + key, + data: objectData, + }, + } as ObjectOperation, + }, + client.Utils, + client.MessageEncoding, + ); + + return [...createValueTypesMessages, mapSetMsg]; + } + /** * @internal */ @@ -173,7 +230,7 @@ export class LiveMap extends LiveObject( realtimeObject: RealtimeObject, key: TKey, - value: API.LiveMapType[TKey], + value: API.LiveMapType[TKey] | LiveCounterValueType | LiveMapValueType, ): void { const client = realtimeObject.getClient(); @@ -354,10 +411,20 @@ export class LiveMap extends LiveObject(key: TKey, value: T[TKey]): Promise { + async set( + key: TKey, + value: T[TKey] | LiveCounterValueType | LiveMapValueType, + ): Promise { this._realtimeObject.throwIfInvalidWriteApiConfiguration(); - const msg = LiveMap.createMapSetMessage(this._realtimeObject, this.getObjectId(), key, value); - return this._realtimeObject.publish([msg]); + + let msgs: ObjectMessage[] = []; + if (LiveCounterValueType.instanceof(value) || LiveMapValueType.instanceof(value)) { + msgs = await LiveMap.createMapSetMessageForValueType(this._realtimeObject, this.getObjectId(), key, value); + } else { + msgs = [LiveMap.createMapSetMessage(this._realtimeObject, this.getObjectId(), key, value)]; + } + + return this._realtimeObject.publish(msgs); } /** diff --git a/src/plugins/objects/livemapvaluetype.ts b/src/plugins/objects/livemapvaluetype.ts new file mode 100644 index 0000000000..ce65440573 --- /dev/null +++ b/src/plugins/objects/livemapvaluetype.ts @@ -0,0 +1,169 @@ +import type * as API from '../../../ably'; +import { LiveCounterValueType } from './livecountervaluetype'; +import { LiveMap, LiveMapObjectData, ObjectIdObjectData, ValueObjectData } from './livemap'; +import { ObjectId } from './objectid'; +import { + createInitialValueJSONString, + ObjectData, + ObjectMessage, + ObjectOperation, + ObjectOperationAction, + ObjectsMapEntry, + ObjectsMapSemantics, + PrimitiveObjectValue, +} from './objectmessage'; +import { RealtimeObject } from './realtimeobject'; + +/** + * A value type class that serves as a simple container for LiveMap data. + * Contains sufficient information for the client to produce a MAP_CREATE operation + * for the LiveMap object. + * + * Properties of this class are immutable after construction and the instance + * will be frozen to prevent mutation. + * + * Note: We do not deep freeze or deep copy the entries data for the following reasons: + * 1. It adds substantial complexity, especially for handling Buffer/ArrayBuffer values + * 2. Cross-platform buffer copying would require reimplementing BufferUtils logic + * to handle browser vs Node.js environments and check availability of Buffer/ArrayBuffer + * 3. The protection isn't critical - if users mutate the data after creating the value type, + * nothing breaks since we create separate live objects each time the value type is used + * 4. This behavior should be documented and it's the user's responsibility to understand + * how they mutate their data when working with value type classes + */ +export class LiveMapValueType = Record> + implements API.LiveMap +{ + declare readonly [API.__livetype]: 'LiveMap'; // type-only, unique symbol to satisfy branded interfaces, no JS emitted + private readonly _livetype = 'LiveMap'; // use a runtime property to provide a reliable cross-bundle type identification instead of `instanceof` operator + private readonly _entries: T | undefined; + + private constructor(entries: T | undefined) { + this._entries = entries; + Object.freeze(this); + } + + static create>( + initialEntries?: T, + ): API.LiveMap ? T : {}> { + // We can't directly import the ErrorInfo class from the core library into the plugin (as this would bloat the plugin size), + // and, since we're in a user-facing static method, we can't expect a user to pass a client library instance, as this would make the API ugly. + // Since we can't use ErrorInfo here, we won't do any validation at this step; instead, validation will happen in the mutation methods + // when we try to create this object. + + return new LiveMapValueType(initialEntries); + } + + /** + * @internal + */ + static instanceof(value: unknown): value is LiveMapValueType { + return typeof value === 'object' && value !== null && (value as LiveMapValueType)._livetype === 'LiveMap'; + } + + /** + * @internal + */ + static async createMapCreateMessage( + realtimeObject: RealtimeObject, + value: LiveMapValueType, + ): Promise<{ mapCreateMsg: ObjectMessage; nestedObjectsCreateMsgs: ObjectMessage[] }> { + const client = realtimeObject.getClient(); + const entries = value._entries; + + if (entries !== undefined && (entries === null || typeof entries !== 'object')) { + throw new client.ErrorInfo('Map entries should be a key-value object', 40003, 400); + } + + // TODO: fix as any type assertion when LiveMap type is updated to support new path based types + Object.entries(entries ?? {}).forEach(([key, value]) => + LiveMap.validateKeyValue(realtimeObject, key, value as any), + ); + + const { initialValueOperation, nestedObjectsCreateMsgs } = await LiveMapValueType._createInitialValueOperation( + realtimeObject, + entries, + ); + const initialValueJSONString = createInitialValueJSONString(initialValueOperation, client); + const nonce = client.Utils.cheapRandStr(); + const msTimestamp = await client.getTimestamp(true); + + const objectId = ObjectId.fromInitialValue( + client.Platform, + 'map', + initialValueJSONString, + nonce, + msTimestamp, + ).toString(); + + const mapCreateMsg = ObjectMessage.fromValues( + { + operation: { + ...initialValueOperation, + action: ObjectOperationAction.MAP_CREATE, + objectId, + nonce, + initialValue: initialValueJSONString, + } as ObjectOperation, + }, + client.Utils, + client.MessageEncoding, + ); + + return { + mapCreateMsg, + nestedObjectsCreateMsgs, + }; + } + + /** + * @internal + */ + private static async _createInitialValueOperation( + realtimeObject: RealtimeObject, + entries?: Record, + ): Promise<{ + initialValueOperation: Pick, 'map'>; + nestedObjectsCreateMsgs: ObjectMessage[]; + }> { + const mapEntries: Record> = {}; + const nestedObjectsCreateMsgs: ObjectMessage[] = []; + + for (const [key, value] of Object.entries(entries ?? {})) { + let objectData: LiveMapObjectData; + + if (LiveMapValueType.instanceof(value)) { + const { mapCreateMsg, nestedObjectsCreateMsgs: childNestedObjs } = + await LiveMapValueType.createMapCreateMessage(realtimeObject, value); + nestedObjectsCreateMsgs.push(...childNestedObjs, mapCreateMsg); + const typedObjectData: ObjectIdObjectData = { objectId: mapCreateMsg.operation?.objectId! }; + objectData = typedObjectData; + } else if (LiveCounterValueType.instanceof(value)) { + const counterCreateMsg = await LiveCounterValueType.createCounterCreateMessage(realtimeObject, value); + nestedObjectsCreateMsgs.push(counterCreateMsg); + const typedObjectData: ObjectIdObjectData = { objectId: counterCreateMsg.operation?.objectId! }; + objectData = typedObjectData; + } else { + // Handle primitive values + const typedObjectData: ValueObjectData = { value: value as PrimitiveObjectValue }; + objectData = typedObjectData; + } + + mapEntries[key] = { + data: objectData, + }; + } + + const initialValueOperation = { + map: { + semantics: ObjectsMapSemantics.LWW, + entries: mapEntries, + }, + }; + + return { + initialValueOperation, + nestedObjectsCreateMsgs, + }; + } +} diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js index 9a5702a638..37a92b26ee 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/objects.test.js @@ -15,6 +15,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const objectsFixturesChannel = 'objects_fixtures'; const nextTick = Ably.Realtime.Platform.Config.nextTick; const gcIntervalOriginal = ObjectsPlugin.RealtimeObject._DEFAULTS.gcInterval; + const LiveMap = ObjectsPlugin.LiveMap; + const LiveCounter = ObjectsPlugin.LiveCounter; function RealtimeWithObjects(helper, options) { return helper.AblyRealtime({ ...options, plugins: { Objects: ObjectsPlugin } }); @@ -597,19 +599,22 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'OBJECT_SYNC sequence builds object tree with all operations applied', action: async (ctx) => { - const { root, realtimeObject, helper, clientOptions, channelName } = ctx; + const { root, helper, clientOptions, channelName } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'map'), ]); + await Promise.all([ + // MAP_CREATE + root.set('map', LiveMap.create({ shouldStay: 'foo', shouldDelete: 'bar' })), + // COUNTER_CREATE + root.set('counter', LiveCounter.create(1)), + objectsCreatedPromise, + ]); - // MAP_CREATE - const map = await realtimeObject.createMap({ shouldStay: 'foo', shouldDelete: 'bar' }); - // COUNTER_CREATE - const counter = await realtimeObject.createCounter(1); - - await Promise.all([root.set('map', map), root.set('counter', counter), objectsCreatedPromise]); + const map = root.get('map'); + const counter = root.get('counter'); const operationsAppliedPromise = Promise.all([ waitForMapKeyUpdate(map, 'anotherKey'), @@ -657,16 +662,20 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'OBJECT_SYNC sequence does not change references to existing objects', action: async (ctx) => { - const { root, realtimeObject, helper, channel } = ctx; + const { root, helper, channel } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'map'), ]); + await Promise.all([ + root.set('map', LiveMap.create()), + root.set('counter', LiveCounter.create()), + objectsCreatedPromise, + ]); + const map = root.get('map'); + const counter = root.get('counter'); - const map = await realtimeObject.createMap(); - const counter = await realtimeObject.createCounter(); - await Promise.all([root.set('map', map), root.set('counter', counter), objectsCreatedPromise]); await channel.detach(); // wait for the actual OBJECT_SYNC message to confirm it was received and processed @@ -3114,40 +3123,25 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, { - allTransportsAndProtocols: true, - description: 'RealtimeObject.createCounter sends COUNTER_CREATE operation', - action: async (ctx) => { - const { realtimeObject } = ctx; - - const counters = await Promise.all( - countersFixtures.map(async (x) => realtimeObject.createCounter(x.count)), - ); - - for (let i = 0; i < counters.length; i++) { - const counter = counters[i]; - const fixture = countersFixtures[i]; - - expect(counter, `Check counter #${i + 1} exists`).to.exist; - expectInstanceOf(counter, 'LiveCounter', `Check counter instance #${i + 1} is of an expected class`); - expect(counter.value()).to.equal( - fixture.count ?? 0, - `Check counter #${i + 1} has expected initial value`, - ); - } + description: 'LiveCounter.create() returns value type object', + action: async () => { + const valueType = LiveCounter.create(); + expectInstanceOf(valueType, 'LiveCounterValueType', `Check LiveCounter.create() returns value type object`); }, }, { allTransportsAndProtocols: true, - description: 'LiveCounter created with RealtimeObject.createCounter can be assigned to the object tree', + description: 'value type created with LiveCounter.create() can be assigned to the object tree', action: async (ctx) => { - const { root, realtimeObject } = ctx; + const { root } = ctx; const counterCreatedPromise = waitForMapKeyUpdate(root, 'counter'); - const counter = await realtimeObject.createCounter(1); - await root.set('counter', counter); + await root.set('counter', LiveCounter.create(1)); await counterCreatedPromise; + const counter = root.get('counter'); + expectInstanceOf(counter, 'LiveCounter', `Check counter instance is of an expected class`); expectInstanceOf( root.get('counter'), @@ -3165,141 +3159,122 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, }, - { - description: - 'RealtimeObject.createCounter can return LiveCounter with initial value without applying CREATE operation', - action: async (ctx) => { - const { realtimeObject, helper } = ctx; - - // prevent publishing of ops to realtime so we guarantee that the initial value doesn't come from a CREATE op - helper.recordPrivateApi('replace.RealtimeObject.publish'); - realtimeObject.publish = () => {}; - - const counter = await realtimeObject.createCounter(1); - expect(counter.value()).to.equal(1, `Check counter has expected initial value`); - }, - }, - { allTransportsAndProtocols: true, - description: - 'RealtimeObject.createCounter can return LiveCounter with initial value from applied CREATE operation', - action: async (ctx) => { - const { realtimeObject, objectsHelper, helper, channel } = ctx; - - // instead of sending CREATE op to the realtime, echo it immediately to the client - // with forged initial value so we can check that counter gets initialized with a value from a CREATE op - helper.recordPrivateApi('replace.RealtimeObject.publish'); - realtimeObject.publish = async (objectMessages) => { - const counterId = objectMessages[0].operation.objectId; - // this should result execute regular operation application procedure and create an object in the pool with forged initial value - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 1, 1), - siteCode: 'aaa', - state: [objectsHelper.counterCreateOp({ objectId: counterId, count: 10 })], - }); - }; - - const counter = await realtimeObject.createCounter(1); - - // counter should be created with forged initial value instead of the actual one - expect(counter.value()).to.equal( - 10, - 'Check counter value has the expected initial value from a CREATE operation', - ); - }, - }, - - { - description: - 'initial value is not double counted for LiveCounter from RealtimeObject.createCounter when CREATE op is received', + description: 'LiveCounter.create() sends COUNTER_CREATE operation', action: async (ctx) => { - const { realtimeObject, objectsHelper, helper, channel } = ctx; - - // prevent publishing of ops to realtime so we can guarantee order of operations - helper.recordPrivateApi('replace.RealtimeObject.publish'); - realtimeObject.publish = () => {}; + const { root } = ctx; - // create counter locally, should have an initial value set - const counter = await realtimeObject.createCounter(1); - helper.recordPrivateApi('call.LiveObject.getObjectId'); - const counterId = counter.getObjectId(); + const objectsCreatedPromise = Promise.all(countersFixtures.map((x) => waitForMapKeyUpdate(root, x.name))); + await Promise.all(countersFixtures.map(async (x) => root.set(x.name, LiveCounter.create(x.count)))); + await objectsCreatedPromise; - // now inject CREATE op for a counter with a forged value. it should not be applied - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 1, 1), - siteCode: 'aaa', - state: [objectsHelper.counterCreateOp({ objectId: counterId, count: 10 })], - }); + for (let i = 0; i < countersFixtures.length; i++) { + const counter = root.get(countersFixtures[i].name); + const fixture = countersFixtures[i]; - expect(counter.value()).to.equal( - 1, - `Check counter initial value is not double counted after being created and receiving CREATE operation`, - ); + expect(counter, `Check counter #${i + 1} exists`).to.exist; + expectInstanceOf(counter, 'LiveCounter', `Check counter instance #${i + 1} is of an expected class`); + expect(counter.value()).to.equal( + fixture.count ?? 0, + `Check counter #${i + 1} has expected initial value`, + ); + } }, }, { - description: 'RealtimeObject.createCounter throws on invalid input', + description: + 'value type created with LiveCounter.create() with an invalid input throws when assigned to the object tree', action: async (ctx) => { - const { root, realtimeObject } = ctx; + const { root } = ctx; await expectToThrowAsync( - async () => realtimeObject.createCounter(null), + async () => root.set('counter', LiveCounter.create(null)), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => realtimeObject.createCounter(Number.NaN), + async () => root.set('counter', LiveCounter.create(Number.NaN)), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => realtimeObject.createCounter(Number.POSITIVE_INFINITY), + async () => root.set('counter', LiveCounter.create(Number.POSITIVE_INFINITY)), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => realtimeObject.createCounter(Number.NEGATIVE_INFINITY), + async () => root.set('counter', LiveCounter.create(Number.NEGATIVE_INFINITY)), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => realtimeObject.createCounter('foo'), + async () => root.set('counter', LiveCounter.create('foo')), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => realtimeObject.createCounter(BigInt(1)), + async () => root.set('counter', LiveCounter.create(BigInt(1))), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => realtimeObject.createCounter(true), + async () => root.set('counter', LiveCounter.create(true)), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => realtimeObject.createCounter(Symbol()), + async () => root.set('counter', LiveCounter.create(Symbol())), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => realtimeObject.createCounter({}), + async () => root.set('counter', LiveCounter.create({})), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => realtimeObject.createCounter([]), + async () => root.set('counter', LiveCounter.create([])), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => realtimeObject.createCounter(root), + async () => root.set('counter', LiveCounter.create(root)), 'Counter value should be a valid number', ); }, }, + { + description: 'LiveMap.create() returns value type object', + action: async () => { + const valueType = LiveMap.create(); + expectInstanceOf(valueType, 'LiveMapValueType', `Check LiveMap.create() returns value type object`); + }, + }, + + { + allTransportsAndProtocols: true, + description: 'value type created with LiveMap.create() can be assigned to the object tree', + action: async (ctx) => { + const { root } = ctx; + + const mapCreatedPromise = waitForMapKeyUpdate(root, 'map'); + await root.set('map', LiveMap.create({ foo: 'bar' })); + await mapCreatedPromise; + + const map = root.get('map'); + + expectInstanceOf(map, 'LiveMap', `Check map instance on root is of an expected class`); + expect(map.size()).to.equal(1, 'Check map assigned to the object tree has the expected number of keys'); + expect(map.get('foo')).to.equal( + 'bar', + 'Check map assigned to the object tree has the expected value for its string key', + ); + }, + }, + { allTransportsAndProtocols: true, - description: 'RealtimeObject.createMap sends MAP_CREATE operation with primitive values', + description: 'LiveMap.create() sends MAP_CREATE operation with primitive values', action: async (ctx) => { - const { realtimeObject, helper } = ctx; + const { root, helper } = ctx; - const maps = await Promise.all( + const objectsCreatedPromise = Promise.all( + primitiveMapsFixtures.map((x) => waitForMapKeyUpdate(root, x.name)), + ); + await Promise.all( primitiveMapsFixtures.map(async (mapFixture) => { const entries = mapFixture.entries ? Object.entries(mapFixture.entries).reduce((acc, [key, keyData]) => { @@ -3318,12 +3293,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, {}) : undefined; - return realtimeObject.createMap(entries); + return root.set(mapFixture.name, LiveMap.create(entries)); }), ); + await objectsCreatedPromise; - for (let i = 0; i < maps.length; i++) { - const map = maps[i]; + for (let i = 0; i < primitiveMapsFixtures.length; i++) { + const map = root.get(primitiveMapsFixtures[i].name); const fixture = primitiveMapsFixtures[i]; expect(map, `Check map #${i + 1} exists`).to.exist; @@ -3349,210 +3325,80 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { allTransportsAndProtocols: true, - description: 'RealtimeObject.createMap sends MAP_CREATE operation with reference to another LiveObject', + description: 'LiveMap.create() sends MAP_CREATE operation with reference to another LiveObject', action: async (ctx) => { - const { root, objectsHelper, channelName, realtimeObject } = ctx; - - const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), - ]); - await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'counter', - createOp: objectsHelper.counterCreateRestOp(), - }); - await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'map', - createOp: objectsHelper.mapCreateRestOp(), - }); - await objectsCreatedPromise; - - const counter = root.get('counter'); - const map = root.get('map'); - - const newMap = await realtimeObject.createMap({ counter, map }); - - expect(newMap, 'Check map exists').to.exist; - expectInstanceOf(newMap, 'LiveMap', 'Check map instance is of an expected class'); - - expect(newMap.get('counter')).to.equal( - counter, - 'Check can set a reference to a LiveCounter object on a new map via a MAP_CREATE operation', - ); - expect(newMap.get('map')).to.equal( - map, - 'Check can set a reference to a LiveMap object on a new map via a MAP_CREATE operation', - ); - }, - }, - - { - allTransportsAndProtocols: true, - description: 'LiveMap created with RealtimeObject.createMap can be assigned to the object tree', - action: async (ctx) => { - const { root, realtimeObject } = ctx; - - const mapCreatedPromise = waitForMapKeyUpdate(root, 'map'); - const counter = await realtimeObject.createCounter(); - const map = await realtimeObject.createMap({ foo: 'bar', baz: counter }); - await root.set('map', map); - await mapCreatedPromise; - - expectInstanceOf(map, 'LiveMap', `Check map instance is of an expected class`); - expectInstanceOf(root.get('map'), 'LiveMap', `Check map instance on root is of an expected class`); - expect(root.get('map')).to.equal(map, 'Check map object on root is the same as from create method'); - expect(root.get('map').size()).to.equal( - 2, - 'Check map assigned to the object tree has the expected number of keys', - ); - expect(root.get('map').get('foo')).to.equal( - 'bar', - 'Check map assigned to the object tree has the expected value for its string key', - ); - expect(root.get('map').get('baz')).to.equal( - counter, - 'Check map assigned to the object tree has the expected value for its LiveCounter key', + const { root } = ctx; + + const objectCreatedPromise = waitForMapKeyUpdate(root, 'map'); + await root.set( + 'map', + LiveMap.create({ + map: LiveMap.create(), + counter: LiveCounter.create(), + }), ); - }, - }, - - { - description: - 'RealtimeObject.createMap can return LiveMap with initial value without applying CREATE operation', - action: async (ctx) => { - const { realtimeObject, helper } = ctx; - - // prevent publishing of ops to realtime so we guarantee that the initial value doesn't come from a CREATE op - helper.recordPrivateApi('replace.RealtimeObject.publish'); - realtimeObject.publish = () => {}; + await objectCreatedPromise; - const map = await realtimeObject.createMap({ foo: 'bar' }); - expect(map.get('foo')).to.equal('bar', `Check map has expected initial value`); - }, - }, + const map = root.get('map'); + const nestedMap = map.get('map'); + const nestedCounter = map.get('counter'); - { - allTransportsAndProtocols: true, - description: 'RealtimeObject.createMap can return LiveMap with initial value from applied CREATE operation', - action: async (ctx) => { - const { realtimeObject, objectsHelper, helper, channel } = ctx; - - // instead of sending CREATE op to the realtime, echo it immediately to the client - // with forged initial value so we can check that map gets initialized with a value from a CREATE op - helper.recordPrivateApi('replace.RealtimeObject.publish'); - realtimeObject.publish = async (objectMessages) => { - const mapId = objectMessages[0].operation.objectId; - // this should result execute regular operation application procedure and create an object in the pool with forged initial value - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 1, 1), - siteCode: 'aaa', - state: [ - objectsHelper.mapCreateOp({ - objectId: mapId, - entries: { baz: { timeserial: lexicoTimeserial('aaa', 1, 1), data: { string: 'qux' } } }, - }), - ], - }); - }; + expect(map, 'Check map exists').to.exist; + expectInstanceOf(map, 'LiveMap', 'Check map instance is of an expected class'); - const map = await realtimeObject.createMap({ foo: 'bar' }); + expect(nestedMap, 'Check nested map exists').to.exist; + expectInstanceOf(nestedMap, 'LiveMap', 'Check nested map instance is of an expected class'); - // map should be created with forged initial value instead of the actual one - expect(map.get('foo'), `Check key "foo" was not set on a map client-side`).to.not.exist; - expect(map.get('baz')).to.equal( - 'qux', - `Check key "baz" was set on a map from a CREATE operation after object creation`, - ); + expect(nestedCounter, 'Check nested counter exists').to.exist; + expectInstanceOf(nestedCounter, 'LiveCounter', 'Check nested counter instance is of an expected class'); }, }, { description: - 'initial value is not double counted for LiveMap from RealtimeObject.createMap when CREATE op is received', + 'value type created with LiveMap.create() with an invalid input throws when assigned to the object tree', action: async (ctx) => { - const { realtimeObject, objectsHelper, helper, channel } = ctx; - - // prevent publishing of ops to realtime so we can guarantee order of operations - helper.recordPrivateApi('replace.RealtimeObject.publish'); - realtimeObject.publish = () => {}; - - // create map locally, should have an initial value set - const map = await realtimeObject.createMap({ foo: 'bar' }); - helper.recordPrivateApi('call.LiveObject.getObjectId'); - const mapId = map.getObjectId(); - - // now inject CREATE op for a map with a forged value. it should not be applied - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 1, 1), - siteCode: 'aaa', - state: [ - objectsHelper.mapCreateOp({ - objectId: mapId, - entries: { - foo: { timeserial: lexicoTimeserial('aaa', 1, 1), data: { string: 'qux' } }, - baz: { timeserial: lexicoTimeserial('aaa', 1, 1), data: { string: 'qux' } }, - }, - }), - ], - }); - - expect(map.get('foo')).to.equal( - 'bar', - `Check key "foo" was not overridden by a CREATE operation after creating a map locally`, - ); - expect(map.get('baz'), `Check key "baz" was not set by a CREATE operation after creating a map locally`).to - .not.exist; - }, - }, - - { - description: 'RealtimeObject.createMap throws on invalid input', - action: async (ctx) => { - const { root, realtimeObject } = ctx; + const { root } = ctx; await expectToThrowAsync( - async () => realtimeObject.createMap(null), + async () => root.set('map', LiveMap.create(null)), 'Map entries should be a key-value object', ); await expectToThrowAsync( - async () => realtimeObject.createMap('foo'), + async () => root.set('map', LiveMap.create('foo')), 'Map entries should be a key-value object', ); await expectToThrowAsync( - async () => realtimeObject.createMap(1), + async () => root.set('map', LiveMap.create(1)), 'Map entries should be a key-value object', ); await expectToThrowAsync( - async () => realtimeObject.createMap(BigInt(1)), + async () => root.set('map', LiveMap.create(BigInt(1))), 'Map entries should be a key-value object', ); await expectToThrowAsync( - async () => realtimeObject.createMap(true), + async () => root.set('map', LiveMap.create(true)), 'Map entries should be a key-value object', ); await expectToThrowAsync( - async () => realtimeObject.createMap(Symbol()), + async () => root.set('map', LiveMap.create(Symbol())), 'Map entries should be a key-value object', ); await expectToThrowAsync( - async () => realtimeObject.createMap({ key: undefined }), + async () => root.set('map', LiveMap.create({ key: undefined })), 'Map value data type is unsupported', ); await expectToThrowAsync( - async () => realtimeObject.createMap({ key: null }), + async () => root.set('map', LiveMap.create({ key: null })), 'Map value data type is unsupported', ); await expectToThrowAsync( - async () => realtimeObject.createMap({ key: BigInt(1) }), + async () => root.set('map', LiveMap.create({ key: BigInt(1) })), 'Map value data type is unsupported', ); await expectToThrowAsync( - async () => realtimeObject.createMap({ key: Symbol() }), + async () => root.set('map', LiveMap.create({ key: Symbol() })), 'Map value data type is unsupported', ); }, @@ -3580,10 +3426,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'map'), ]); - const counter = await realtimeObject.createCounter(1); - const map = await realtimeObject.createMap({ innerCounter: counter }); - await root.set('counter', counter); - await root.set('map', map); + await root.set('counter', LiveCounter.create(1)); + await root.set('map', LiveMap.create({ innerCounter: LiveCounter.create(1) })); await objectsCreatedPromise; await realtimeObject.batch((ctx) => { @@ -3623,10 +3467,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'map'), ]); - const counter = await realtimeObject.createCounter(1); - const map = await realtimeObject.createMap({ foo: 'bar' }); - await root.set('counter', counter); - await root.set('map', map); + await root.set('counter', LiveCounter.create(1)); + await root.set('map', LiveMap.create({ foo: 'bar' })); await objectsCreatedPromise; await realtimeObject.batch((ctx) => { @@ -3665,10 +3507,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'map'), ]); - const counter = await realtimeObject.createCounter(1); - const map = await realtimeObject.createMap({ foo: 'bar' }); - await root.set('counter', counter); - await root.set('map', map); + await root.set('counter', LiveCounter.create(1)); + await root.set('map', LiveMap.create({ foo: 'bar' })); await objectsCreatedPromise; await realtimeObject.batch((ctx) => { @@ -3713,12 +3553,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'map'), ]); - const counter = await realtimeObject.createCounter(1); - const map = await realtimeObject.createMap({ foo: 'bar' }); - await root.set('counter', counter); - await root.set('map', map); + await root.set('counter', LiveCounter.create(1)); + await root.set('map', LiveMap.create({ foo: 'bar' })); await objectsCreatedPromise; + const counter = root.get('counter'); + const map = root.get('map'); + await realtimeObject.batch((ctx) => { const ctxRoot = ctx.get(); const ctxCounter = ctxRoot.get('counter'); @@ -3764,12 +3605,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'map'), ]); - const counter = await realtimeObject.createCounter(1); - const map = await realtimeObject.createMap({ foo: 'bar' }); - await root.set('counter', counter); - await root.set('map', map); + await root.set('counter', LiveCounter.create(1)); + await root.set('map', LiveMap.create({ foo: 'bar' })); await objectsCreatedPromise; + const counter = root.get('counter'); + const map = root.get('map'); + const cancelError = new Error('cancel batch'); let caughtError; try { @@ -3809,10 +3651,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'map'), ]); - const counter = await realtimeObject.createCounter(1); - const map = await realtimeObject.createMap({ foo: 'bar' }); - await root.set('counter', counter); - await root.set('map', map); + await root.set('counter', LiveCounter.create(1)); + await root.set('map', LiveMap.create({ foo: 'bar' })); await objectsCreatedPromise; let savedCtx; @@ -3850,10 +3690,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'map'), ]); - const counter = await realtimeObject.createCounter(1); - const map = await realtimeObject.createMap({ foo: 'bar' }); - await root.set('counter', counter); - await root.set('map', map); + await root.set('counter', LiveCounter.create(1)); + await root.set('map', LiveMap.create({ foo: 'bar' })); await objectsCreatedPromise; let savedCtx; @@ -4047,16 +3885,18 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.path() returns correct path strings', action: async (ctx) => { - const { root, realtimeObject, entryPathObject } = ctx; + const { root, entryPathObject } = ctx; const keysUpdatedPromise = Promise.all([waitForMapKeyUpdate(root, 'nested')]); - const nestedMap = await realtimeObject.createMap({ - simple: 'value', - deep: await realtimeObject.createMap({ nested: 'deepValue' }), - 'key.with.dots': 'dottedValue', - 'key\\escaped': 'escapedValue', - }); - await root.set('nested', nestedMap); + await root.set( + 'nested', + LiveMap.create({ + simple: 'value', + 'key.with.dots': 'dottedValue', + 'key\\escaped': 'escapedValue', + deep: LiveMap.create({ nested: 'deepValue' }), + }), + ); await keysUpdatedPromise; // Test path with .get() method @@ -4095,12 +3935,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.at() navigates using dot-separated paths', action: async (ctx) => { - const { root, realtimeObject, entryPathObject } = ctx; + const { root, entryPathObject } = ctx; // Create nested structure const keyUpdatedPromise = waitForMapKeyUpdate(root, 'nested'); - const nestedMap = await realtimeObject.createMap({ deepKey: 'deepValue', 'key.with.dots': 'dottedValue' }); - await root.set('nested', nestedMap); + await root.set('nested', LiveMap.create({ deepKey: 'deepValue', 'key.with.dots': 'dottedValue' })); await keyUpdatedPromise; const nestedPathObj = entryPathObject.at('nested.deepKey'); @@ -4124,13 +3963,15 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject resolves complex path strings', action: async (ctx) => { - const { root, realtimeObject, entryPathObject } = ctx; + const { root, entryPathObject } = ctx; const keyUpdatedPromise = waitForMapKeyUpdate(root, 'nested.key'); - const nestedMap = await realtimeObject.createMap({ - 'key.with.dots.and\\escaped\\characters': 'nestedValue', - }); - await root.set('nested.key', nestedMap); + await root.set( + 'nested.key', + LiveMap.create({ + 'key.with.dots.and\\escaped\\characters': 'nestedValue', + }), + ); await keyUpdatedPromise; // Test complex path via chaining .get() @@ -4197,11 +4038,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.value() returns LiveCounter values', action: async (ctx) => { - const { root, realtimeObject, entryPathObject } = ctx; + const { root, entryPathObject } = ctx; const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); - const counter = await realtimeObject.createCounter(10); - await root.set('counter', counter); + await root.set('counter', LiveCounter.create(10)); await keyUpdatedPromise; const counterPathObj = entryPathObject.get('counter'); @@ -4213,14 +4053,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.instance() returns DefaultInstance for LiveMap and LiveCounter', action: async (ctx) => { - const { root, realtimeObject, entryPathObject } = ctx; + const { root, entryPathObject } = ctx; const keysUpdatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'map'), waitForMapKeyUpdate(root, 'counter'), ]); - await root.set('map', await realtimeObject.createMap()); - await root.set('counter', await realtimeObject.createCounter()); + await root.set('map', LiveMap.create()); + await root.set('counter', LiveCounter.create()); await keysUpdatedPromise; const counterInstance = entryPathObject.get('counter').instance(); @@ -4315,14 +4155,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.set() works for LiveMap objects with LiveObject references', action: async (ctx) => { - const { root, realtimeObject, entryPathObject } = ctx; + const { root, entryPathObject } = ctx; const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counterKey'); - const counter = await realtimeObject.createCounter(5); - await entryPathObject.set('counterKey', counter); + await entryPathObject.set('counterKey', LiveCounter.create(5)); await keyUpdatedPromise; - expect(root.get('counterKey')).to.equal(counter, 'Check counter object was set via PathObject'); + expect(root.get('counterKey'), 'Check counter object was set via PathObject').to.exist; expect(entryPathObject.get('counterKey').value()).to.equal(5, 'Check PathObject reflects counter value'); }, }, @@ -4353,12 +4192,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.increment() and PathObject.decrement() work for LiveCounter objects', action: async (ctx) => { - const { root, realtimeObject, entryPathObject } = ctx; + const { root, entryPathObject } = ctx; const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); - const counter = await realtimeObject.createCounter(10); - await root.set('counter', counter); + await root.set('counter', LiveCounter.create(10)); await keyUpdatedPromise; + const counter = root.get('counter'); const counterPathObj = entryPathObject.get('counter'); @@ -4468,11 +4307,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject handling of operations for paths with non-collection intermediate segments', action: async (ctx) => { - const { root, realtimeObject, entryPathObject } = ctx; + const { root, entryPathObject } = ctx; const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); - const counter = await realtimeObject.createCounter(); - await root.set('counter', counter); + await root.set('counter', LiveCounter.create()); await keyUpdatedPromise; const wrongTypePathObj = entryPathObject.at('counter.nested.path'); @@ -4515,17 +4353,15 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject handling of operations on wrong underlying object type', action: async (ctx) => { - const { root, realtimeObject, entryPathObject } = ctx; + const { root, entryPathObject } = ctx; const keysUpdatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'map'), waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'primitive'), ]); - const map = await realtimeObject.createMap(); - const counter = await realtimeObject.createCounter(5); - await root.set('map', map); - await root.set('counter', counter); + await root.set('map', LiveMap.create()); + await root.set('counter', LiveCounter.create()); await root.set('primitive', 'value'); await keysUpdatedPromise; @@ -4614,18 +4450,20 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.id() returns object ID of underlying LiveObject', action: async (ctx) => { - const { root, realtimeObject, entryPathObject, helper } = ctx; + const { root, entryPathObject, helper } = ctx; const keysUpdatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'map'), waitForMapKeyUpdate(root, 'counter'), ]); - const map = await realtimeObject.createMap(); - const counter = await realtimeObject.createCounter(); - await entryPathObject.set('map', map); - await entryPathObject.set('counter', counter); + + await entryPathObject.set('map', LiveMap.create()); + await entryPathObject.set('counter', LiveCounter.create()); await keysUpdatedPromise; + const map = root.get('map'); + const counter = root.get('counter'); + const mapInstance = entryPathObject.get('map').instance(); const counterInstance = entryPathObject.get('counter').instance(); @@ -4643,14 +4481,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.get() returns child DefaultInstance instances', action: async (ctx) => { - const { root, realtimeObject, entryPathObject } = ctx; + const { root, entryPathObject } = ctx; const keysUpdatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'stringKey'), waitForMapKeyUpdate(root, 'counterKey'), ]); await entryPathObject.set('stringKey', 'value'); - await entryPathObject.set('counterKey', await realtimeObject.createCounter(42)); + await entryPathObject.set('counterKey', LiveCounter.create(42)); await keysUpdatedPromise; const rootInstance = entryPathObject.instance(); @@ -4707,11 +4545,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.value() returns LiveCounter values', action: async (ctx) => { - const { root, realtimeObject, entryPathObject } = ctx; + const { root, entryPathObject } = ctx; const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); - const counter = await realtimeObject.createCounter(10); - await entryPathObject.set('counter', counter); + await entryPathObject.set('counter', LiveCounter.create(10)); await keyUpdatedPromise; const counterInstance = entryPathObject.get('counter').instance(); @@ -4805,16 +4642,15 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.set() works for LiveMap objects with LiveObject references', action: async (ctx) => { - const { root, realtimeObject, entryPathObject } = ctx; + const { root, entryPathObject } = ctx; const rootInstance = entryPathObject.instance(); const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counterKey'); - const counter = await realtimeObject.createCounter(5); - await rootInstance.set('counterKey', counter); + await rootInstance.set('counterKey', LiveCounter.create(5)); await keyUpdatedPromise; - expect(root.get('counterKey')).to.equal(counter, 'Check counter object was set via DefaultInstance'); + expect(root.get('counterKey'), 'Check counter object was set via DefaultInstance').to.exist; expect(rootInstance.get('counterKey').value()).to.equal(5, 'Check DefaultInstance reflects counter value'); }, }, @@ -4848,14 +4684,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.increment() and DefaultInstance.decrement() work for LiveCounter objects', action: async (ctx) => { - const { root, realtimeObject, entryPathObject } = ctx; + const { root, entryPathObject } = ctx; const rootInstance = entryPathObject.instance(); const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); - const counter = await realtimeObject.createCounter(10); - await entryPathObject.set('counter', counter); + await entryPathObject.set('counter', LiveCounter.create(10)); await keyUpdatedPromise; + const counter = root.get('counter'); const counterInstance = rootInstance.get('counter'); @@ -4910,17 +4746,15 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance handling of operations on wrong underlying object type', action: async (ctx) => { - const { root, realtimeObject, entryPathObject } = ctx; + const { root, entryPathObject } = ctx; const keysUpdatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'map'), waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'primitive'), ]); - const map = await realtimeObject.createMap({ foo: 'bar' }); - const counter = await realtimeObject.createCounter(5); - await entryPathObject.set('map', map); - await entryPathObject.set('counter', counter); + await entryPathObject.set('map', LiveMap.create({ foo: 'bar' })); + await entryPathObject.set('counter', LiveCounter.create()); await entryPathObject.set('primitive', 'value'); await keysUpdatedPromise; @@ -5996,8 +5830,6 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const expectWriteApiToThrow = async ({ realtimeObject, map, counter, errorMsg }) => { await expectToThrowAsync(async () => realtimeObject.batch(), errorMsg); - await expectToThrowAsync(async () => realtimeObject.createMap(), errorMsg); - await expectToThrowAsync(async () => realtimeObject.createCounter(), errorMsg); await expectToThrowAsync(async () => counter.increment(), errorMsg); await expectToThrowAsync(async () => counter.decrement(), errorMsg); @@ -6207,12 +6039,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function waitForMapKeyUpdate(root, 'map'), waitForMapKeyUpdate(root, 'counter'), ]); - const map = await realtimeObject.createMap(); - const counter = await realtimeObject.createCounter(); - await root.set('map', map); - await root.set('counter', counter); + await root.set('map', LiveMap.create()); + await root.set('counter', LiveCounter.create()); await objectsCreatedPromise; + const map = root.get('map'); + const counter = root.get('counter'); + await scenario.action({ realtimeObject, objectsHelper, From 72dc80d3d4ecac616a1d473c1b0899fe9435aad0 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Wed, 15 Oct 2025 09:29:09 +0100 Subject: [PATCH 10/45] Remove obsolete LiveObject subscription API This was replaced by PathObject/Instance subscription API from previous commits --- ably.d.ts | 84 +- src/plugins/objects/instance.ts | 12 +- src/plugins/objects/livecounter.ts | 12 +- src/plugins/objects/livemap.ts | 16 +- src/plugins/objects/liveobject.ts | 89 +- src/plugins/objects/objectmessage.ts | 8 + src/plugins/objects/pathobject.ts | 3 +- .../objects/pathobjectsubscriptionregister.ts | 8 +- src/plugins/objects/realtimeobject.ts | 1 - test/realtime/objects.test.js | 1035 ++++++++--------- 10 files changed, 539 insertions(+), 729 deletions(-) diff --git a/ably.d.ts b/ably.d.ts index 20339661db..448142fa00 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -1652,13 +1652,6 @@ export type ErrorCallback = (error: ErrorInfo | null) => void; */ export type EventCallback = (event: T) => void; -/** - * A callback used in {@link LiveObjectDeprecated} to listen for updates to the object. - * - * @param update - The update object describing the changes made to the object. - */ -export type LiveObjectUpdateCallback = (update: T) => void; - /** * The callback used for the events emitted by {@link RealtimeObject}. */ @@ -3391,7 +3384,6 @@ export declare interface BatchContextLiveMap { * * This does not modify the underlying data of this object. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. * * @param key - The key to set the value for. * @param value - The value to assign to the key. @@ -3404,7 +3396,6 @@ export declare interface BatchContextLiveMap { * * This does not modify the underlying data of this object. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. * * @param key - The key to set the value for. * @experimental @@ -3428,7 +3419,6 @@ export declare interface BatchContextLiveCounter { * * This does not modify the underlying data of this object. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. * * @param amount - The amount by which to increase the counter value. * @experimental @@ -3451,7 +3441,7 @@ export declare interface BatchContextLiveCounter { * * Keys must be strings. Values can be another {@link LiveObjectDeprecated}, or a primitive type, such as a string, number, boolean, JSON-serializable object or array, or binary data (see {@link PrimitiveObjectValue}). */ -export declare interface LiveMapDeprecated extends LiveObjectDeprecated> { +export declare interface LiveMapDeprecated extends LiveObjectDeprecated { /** * Returns the value associated with a given key. Returns `undefined` if the key doesn't exist in a map or if the associated {@link LiveObjectDeprecated} has been deleted. * @@ -3496,7 +3486,6 @@ export declare interface LiveMapDeprecated extends LiveOb * * This does not modify the underlying data of this object. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. * * @param key - The key to set the value for. * @param value - The value to assign to the key. @@ -3510,7 +3499,6 @@ export declare interface LiveMapDeprecated extends LiveOb * * This does not modify the underlying data of this object. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. * * @param key - The key to remove. * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. @@ -3519,18 +3507,6 @@ export declare interface LiveMapDeprecated extends LiveOb remove(key: TKey): Promise; } -/** - * Represents an update to a {@link LiveMapDeprecated} object, describing the keys that were updated or removed. - */ -export declare interface LiveMapUpdate extends LiveObjectUpdate { - /** - * An object containing keys from a `LiveMap` that have changed, along with their change status: - * - `updated` - the value of a key in the map was updated. - * - `removed` - the key was removed from the map. - */ - update: { [keyName in keyof T & string]?: 'updated' | 'removed' }; -} - /** * Represents a primitive value that can be stored in a {@link LiveMapDeprecated}. * @@ -3561,7 +3537,7 @@ export type JsonObject = { [prop: string]: Json | undefined }; /** * The `LiveCounter` class represents a counter that can be incremented or decremented and is synchronized across clients in realtime. */ -export declare interface LiveCounterDeprecated extends LiveObjectDeprecated { +export declare interface LiveCounterDeprecated extends LiveObjectDeprecated { /** * Returns the current value of the counter. * @@ -3574,7 +3550,6 @@ export declare interface LiveCounterDeprecated extends LiveObjectDeprecated; } -/** - * Represents an update to a {@link LiveCounterDeprecated} object. - */ -export declare interface LiveCounterUpdate extends LiveObjectUpdate { - /** - * Holds the numerical change to the counter value. - */ - update: { - /** - * The value by which the counter was incremented or decremented. - */ - amount: number; - }; -} - /** * Describes the common interface for all conflict-free data structures supported by the Objects. */ -export declare interface LiveObjectDeprecated { - /** - * Registers a listener that is called each time this LiveObject is updated. - * - * @param listener - An event listener function that is called with an update object whenever this LiveObject is updated. - * @returns A {@link SubscribeResponse} object that allows the provided listener to be deregistered from future updates. - * @experimental - */ - subscribe(listener: LiveObjectUpdateCallback): SubscribeResponse; - - /** - * Deregisters the given listener from updates for this LiveObject. - * - * @param listener - An event listener function. - * @experimental - */ - unsubscribe(listener: LiveObjectUpdateCallback): void; - - /** - * Deregisters all listeners from updates for this LiveObject. - * - * @experimental - */ - unsubscribeAll(): void; - +export declare interface LiveObjectDeprecated { /** * Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. * @@ -3662,20 +3598,6 @@ export declare interface LiveObjectDeprecated ); } - this.notifyUpdated(update, msg); + this.notifyUpdated(update); } /** @@ -221,8 +221,8 @@ export class LiveCounter extends LiveObject // if object got tombstoned, the update object will include all data that got cleared. // otherwise it is a diff between previous value and new value from object state. const update = this._updateFromDataDiff(previousDataRef, this._dataRef); - update.clientId = objectMessage.clientId; - update.connectionId = objectMessage.connectionId; + update.objectMessage = objectMessage; + return update; } @@ -257,8 +257,7 @@ export class LiveCounter extends LiveObject return { update: { amount: objectOperation.counter?.count ?? 0 }, - clientId: msg.clientId, - connectionId: msg.connectionId, + objectMessage: msg, _type: 'LiveCounterUpdate', }; } @@ -295,8 +294,7 @@ export class LiveCounter extends LiveObject this._dataRef.data += op.amount; return { update: { amount: op.amount }, - clientId: msg.clientId, - connectionId: msg.connectionId, + objectMessage: msg, _type: 'LiveCounterUpdate', }; } diff --git a/src/plugins/objects/livemap.ts b/src/plugins/objects/livemap.ts index 298973aa4c..c7a5fbb101 100644 --- a/src/plugins/objects/livemap.ts +++ b/src/plugins/objects/livemap.ts @@ -417,7 +417,7 @@ export class LiveMap extends LiveObject extends LiveObject extends LiveObject = { update: {}, - clientId: msg.clientId, - connectionId: msg.connectionId, + objectMessage: msg, _type: 'LiveMapUpdate', }; // RTLM6d1 @@ -764,8 +762,7 @@ export class LiveMap extends LiveObject = { update: {}, - clientId: msg.clientId, - connectionId: msg.connectionId, + objectMessage: msg, _type: 'LiveMapUpdate', }; const typedKey: keyof T & string = op.key; @@ -835,8 +832,7 @@ export class LiveMap extends LiveObject = { update: {}, - clientId: msg.clientId, - connectionId: msg.connectionId, + objectMessage: msg, _type: 'LiveMapUpdate', }; const typedKey: keyof T & string = op.key; diff --git a/src/plugins/objects/liveobject.ts b/src/plugins/objects/liveobject.ts index 5b9db44c49..e6959a0440 100644 --- a/src/plugins/objects/liveobject.ts +++ b/src/plugins/objects/liveobject.ts @@ -1,6 +1,6 @@ import type BaseClient from 'common/lib/client/baseclient'; import type EventEmitter from 'common/lib/util/eventemitter'; -import type { EventCallback, LiveMapType } from '../../../ably'; +import type { EventCallback, LiveMapType, SubscribeResponse } from '../../../ably'; import { ROOT_OBJECT_ID } from './constants'; import { InstanceEvent } from './instance'; import { LiveMapUpdate } from './livemap'; @@ -18,9 +18,10 @@ export interface LiveObjectData { export interface LiveObjectUpdate { _type: 'LiveMapUpdate' | 'LiveCounterUpdate'; + /** Delta of the change */ update: any; - clientId?: string; - connectionId?: string; + /** Object message that caused an update to an object, if available */ + objectMessage?: ObjectMessage; } export interface LiveObjectUpdateNoop { @@ -29,10 +30,6 @@ export interface LiveObjectUpdateNoop { noop: true; } -export interface SubscribeResponse { - unsubscribe(): void; -} - export enum LiveObjectLifecycleEvent { deleted = 'deleted', } @@ -49,7 +46,6 @@ export abstract class LiveObject< > { protected _client: BaseClient; protected _subscriptions: EventEmitter; - protected _instanceSubscriptions: EventEmitter; protected _lifecycleEvents: EventEmitter; protected _objectId: string; /** @@ -73,7 +69,6 @@ export abstract class LiveObject< ) { this._client = this._realtimeObject.getClient(); this._subscriptions = new this._client.EventEmitter(this._client.logger); - this._instanceSubscriptions = new this._client.EventEmitter(this._client.logger); this._lifecycleEvents = new this._client.EventEmitter(this._client.logger); this._objectId = objectId; this._dataRef = this._getZeroValueData(); @@ -84,7 +79,7 @@ export abstract class LiveObject< this._parentReferences = new Map>(); } - subscribe(listener: (update: TUpdate) => void): SubscribeResponse { + instanceSubscribe(listener: EventCallback): SubscribeResponse { this._realtimeObject.throwIfInvalidAccessApiConfiguration(); this._subscriptions.on(LiveObjectSubscriptionEvent.updated, listener); @@ -96,37 +91,6 @@ export abstract class LiveObject< return { unsubscribe }; } - // TODO: replace obsolete .subscribe call with this one when we completely remove previous API and switch to path-based one - instanceSubscribe(listener: EventCallback): SubscribeResponse { - this._realtimeObject.throwIfInvalidAccessApiConfiguration(); - - this._instanceSubscriptions.on(LiveObjectSubscriptionEvent.updated, listener); - - const unsubscribe = () => { - this._instanceSubscriptions.off(LiveObjectSubscriptionEvent.updated, listener); - }; - - return { unsubscribe }; - } - - unsubscribe(listener: (update: TUpdate) => void): void { - // this public API method can be called without specific configuration, so checking for invalid settings is unnecessary. - - // current implementation of the EventEmitter will remove all listeners if .off is called without arguments or with nullish arguments. - // or when called with just an event argument, it will remove all listeners for the event. - // thus we need to check that listener does actually exist before calling .off. - if (this._client.Utils.isNil(listener)) { - return; - } - - this._subscriptions.off(LiveObjectSubscriptionEvent.updated, listener); - } - - unsubscribeAll(): void { - // this public API method can be called without specific configuration, so checking for invalid settings is unnecessary. - this._subscriptions.off(LiveObjectSubscriptionEvent.updated); - } - on(event: LiveObjectLifecycleEvent, callback: LiveObjectLifecycleEventCallback): OnLiveObjectLifecycleEventResponse { // this public API method can be called without specific configuration, so checking for invalid settings is unnecessary. this._lifecycleEvents.on(event, callback); @@ -167,14 +131,14 @@ export abstract class LiveObject< * * @internal */ - notifyUpdated(update: TUpdate | LiveObjectUpdateNoop, objectMessage?: ObjectMessage): void { - // should not emit update event if update was noop - if ((update as LiveObjectUpdateNoop).noop) { + notifyUpdated(update: TUpdate | LiveObjectUpdateNoop): void { + if (this._isNoopUpdate(update)) { + // do not emit update events for noop updates return; } - this._notifyInstanceSubscriptions(update as TUpdate, objectMessage); - this._notifyPathSubscriptions(update as TUpdate, objectMessage); + this._notifyInstanceSubscriptions(update); + this._notifyPathSubscriptions(update); } /** @@ -196,14 +160,13 @@ export abstract class LiveObject< this._tombstonedAt = Date.now(); // best-effort estimate since no timestamp provided by the server } const update = this.clearData(); - update.clientId = objectMessage.clientId; - update.connectionId = objectMessage.connectionId; + update.objectMessage = objectMessage; + this._lifecycleEvents.emit(LiveObjectLifecycleEvent.deleted); // notify subscribers about the delete operation and then deregister all listeners - this.notifyUpdated(update, objectMessage); + this.notifyUpdated(update); this._subscriptions.off(); - this._instanceSubscriptions.off(); } /** @@ -348,25 +311,19 @@ export abstract class LiveObject< this.tombstone(objectMessage); } - private _updateWithoutType(update: TUpdate): Omit { - const { _type, ...publicUpdate } = update as TUpdate; - return publicUpdate; - } - - private _notifyInstanceSubscriptions(update: TUpdate, objectMessage?: ObjectMessage): void { - this._subscriptions.emit(LiveObjectSubscriptionEvent.updated, this._updateWithoutType(update as TUpdate)); - + private _notifyInstanceSubscriptions(update: TUpdate): void { const event: InstanceEvent = { - message: objectMessage, + // Do not expose object sync messages as they do not represent a single operation on an object + message: update.objectMessage?.isOperationMessage() ? update.objectMessage : undefined, }; - this._instanceSubscriptions.emit(LiveObjectSubscriptionEvent.updated, event); + this._subscriptions.emit(LiveObjectSubscriptionEvent.updated, event); } /** * Notifies path-based subscriptions about changes to this object. * For LiveMapUpdate events, also creates non-bubbling events for each updated key. */ - private _notifyPathSubscriptions(update: TUpdate, objectMessage?: ObjectMessage): void { + private _notifyPathSubscriptions(update: TUpdate): void { const paths = this.getFullPaths(); if (paths.length === 0) { @@ -374,9 +331,11 @@ export abstract class LiveObject< return; } + // Do not expose object sync messages as they do not represent a single operation on an object + const operationObjectMessage = update.objectMessage?.isOperationMessage() ? update.objectMessage : undefined; const pathEvents: PathEvent[] = paths.map((path) => ({ path, - message: objectMessage, + message: operationObjectMessage, bubbles: true, })); @@ -388,7 +347,7 @@ export abstract class LiveObject< for (const basePath of paths) { pathEvents.push({ path: [...basePath, key], - message: objectMessage, + message: operationObjectMessage, bubbles: false, }); } @@ -398,6 +357,10 @@ export abstract class LiveObject< this._realtimeObject.getPathObjectSubscriptionRegister().notifyPathEvents(pathEvents); } + private _isNoopUpdate(update: TUpdate | LiveObjectUpdateNoop): update is LiveObjectUpdateNoop { + return (update as LiveObjectUpdateNoop).noop === true; + } + /** * Apply object operation message on this LiveObject. * diff --git a/src/plugins/objects/objectmessage.ts b/src/plugins/objects/objectmessage.ts index 5481cc744d..26cdd694e1 100644 --- a/src/plugins/objects/objectmessage.ts +++ b/src/plugins/objects/objectmessage.ts @@ -438,6 +438,14 @@ export class ObjectMessage { return strMsg(this, 'ObjectMessage'); } + isOperationMessage(): boolean { + return this.operation != null; + } + + isSyncMessage(): boolean { + return this.object != null; + } + toUserFacingMessage(channel: RealtimeChannel): API.ObjectMessage { return { id: this.id!, diff --git a/src/plugins/objects/pathobject.ts b/src/plugins/objects/pathobject.ts index 04249baa0c..9343bd5089 100644 --- a/src/plugins/objects/pathobject.ts +++ b/src/plugins/objects/pathobject.ts @@ -8,12 +8,13 @@ import type { PathObjectSubscriptionEvent, PathObjectSubscriptionOptions, Primitive, + SubscribeResponse, Value, } from '../../../ably'; import { DefaultInstance } from './instance'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; -import { LiveObject, SubscribeResponse } from './liveobject'; +import { LiveObject } from './liveobject'; import { RealtimeObject } from './realtimeobject'; /** diff --git a/src/plugins/objects/pathobjectsubscriptionregister.ts b/src/plugins/objects/pathobjectsubscriptionregister.ts index 16fc95676e..588cdd440d 100644 --- a/src/plugins/objects/pathobjectsubscriptionregister.ts +++ b/src/plugins/objects/pathobjectsubscriptionregister.ts @@ -1,6 +1,10 @@ import type BaseClient from 'common/lib/client/baseclient'; -import type { EventCallback, PathObjectSubscriptionEvent, PathObjectSubscriptionOptions } from '../../../ably'; -import { SubscribeResponse } from './liveobject'; +import type { + EventCallback, + PathObjectSubscriptionEvent, + PathObjectSubscriptionOptions, + SubscribeResponse, +} from '../../../ably'; import { ObjectMessage } from './objectmessage'; import { DefaultPathObject } from './pathobject'; import { RealtimeObject } from './realtimeobject'; diff --git a/src/plugins/objects/realtimeobject.ts b/src/plugins/objects/realtimeobject.ts index 52cef43b09..32b4b87767 100644 --- a/src/plugins/objects/realtimeobject.ts +++ b/src/plugins/objects/realtimeobject.ts @@ -391,7 +391,6 @@ export class RealtimeObject { this._rebuildAllParentReferences(); // call subscription callbacks for all updated existing objects. - // do not expose the object message as part of the update, since this is an object sync message and does not represent a single operation. existingObjectUpdates.forEach(({ object, update }) => object.notifyUpdated(update)); } diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js index cf41a79ab2..400e83b211 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/objects.test.js @@ -95,10 +95,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function return ObjectsPlugin.ObjectMessage.fromValues(values, Utils, MessageEncoding); } - async function waitForMapKeyUpdate(map, key) { + async function waitForMapKeyUpdate(mapInstance, key) { return new Promise((resolve) => { - const { unsubscribe } = map.subscribe(({ update }) => { - if (update[key]) { + const { unsubscribe } = mapInstance.subscribe(({ message }) => { + if (message?.operation?.mapOp?.key === key) { unsubscribe(); resolve(); } @@ -106,9 +106,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); } - async function waitForCounterUpdate(counter) { + async function waitForCounterUpdate(counterInstance) { return new Promise((resolve) => { - const { unsubscribe } = counter.subscribe(() => { + const { unsubscribe } = counterInstance.subscribe(() => { unsubscribe(); resolve(); }); @@ -172,9 +172,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const expectedKeys = ObjectsHelper.fixtureRootKeys(); await channel.attach(); - const root = await channel.object.get(); + const entryPathObject = await channel.object.getPathObject(); + const entryInstance = entryPathObject.instance(); - await Promise.all(expectedKeys.map((key) => (root.get(key) ? undefined : waitForMapKeyUpdate(root, key)))); + await Promise.all( + expectedKeys.map((key) => (entryInstance.get(key) ? undefined : waitForMapKeyUpdate(entryInstance, key))), + ); } describe('realtime/objects', function () { @@ -599,22 +602,22 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'OBJECT_SYNC sequence builds object tree with all operations applied', action: async (ctx) => { - const { root, helper, clientOptions, channelName } = ctx; + const { helper, clientOptions, channelName, entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), ]); await Promise.all([ // MAP_CREATE - root.set('map', LiveMap.create({ shouldStay: 'foo', shouldDelete: 'bar' })), + entryInstance.set('map', LiveMap.create({ shouldStay: 'foo', shouldDelete: 'bar' })), // COUNTER_CREATE - root.set('counter', LiveCounter.create(1)), + entryInstance.set('counter', LiveCounter.create(1)), objectsCreatedPromise, ]); - const map = root.get('map'); - const counter = root.get('counter'); + const map = entryInstance.get('map'); + const counter = entryInstance.get('counter'); const operationsAppliedPromise = Promise.all([ waitForMapKeyUpdate(map, 'anotherKey'), @@ -662,11 +665,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'OBJECT_SYNC sequence does not change references to existing objects', action: async (ctx) => { - const { root, helper, channel } = ctx; + const { root, helper, channel, entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), ]); await Promise.all([ root.set('map', LiveMap.create()), @@ -881,9 +884,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'OBJECT_SYNC sequence with "tombstone=true" for an object deletes existing object', action: async (ctx) => { - const { root, objectsHelper, channelName, channel } = ctx; + const { root, objectsHelper, channelName, channel, entryInstance } = ctx; - const counterCreatedPromise = waitForMapKeyUpdate(root, 'counter'); + const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); const { objectId: counterId } = await objectsHelper.createAndSetOnMap(channelName, { mapObjectId: 'root', key: 'counter', @@ -934,9 +937,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'OBJECT_SYNC sequence with "tombstone=true" for an object triggers subscription callback for existing object', action: async (ctx) => { - const { root, objectsHelper, channelName, channel } = ctx; + const { root, objectsHelper, channelName, channel, entryInstance } = ctx; - const counterCreatedPromise = waitForMapKeyUpdate(root, 'counter'); + const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); const { objectId: counterId } = await objectsHelper.createAndSetOnMap(channelName, { mapObjectId: 'root', key: 'counter', @@ -944,12 +947,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await counterCreatedPromise; + const counter = entryInstance.get('counter'); + const counterSubPromise = new Promise((resolve, reject) => - root.get('counter').subscribe((update) => { + counter.subscribe((event) => { try { - expect(update?.update).to.deep.equal( - { amount: -1 }, - 'Check counter subscription callback is called with an expected update object after OBJECT_SYNC sequence with "tombstone=true"', + expect(event.object).to.equal( + counter, + 'Check counter subscription callback is called with the correct object', ); resolve(); } catch (error) { @@ -958,7 +963,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }), ); - // inject an OBJECT_SYNC message where a counter is now tombstoned + // inject an OBJECT_SYNC message where counter is now tombstoned await objectsHelper.processObjectStateMessageOnChannel({ channel, syncSerial: 'serial:', // empty serial so sync sequence ends immediately @@ -1155,7 +1160,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'can apply MAP_CREATE with primitives object operation messages', action: async (ctx) => { - const { root, objectsHelper, channelName, helper } = ctx; + const { root, objectsHelper, channelName, helper, entryInstance } = ctx; // Objects public API allows us to check value of objects we've created based on MAP_CREATE ops // if we assign those objects to another map (root for example), as there is no way to access those objects from the internal pool directly. @@ -1168,7 +1173,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function .exist; }); - const mapsCreatedPromise = Promise.all(primitiveMapsFixtures.map((x) => waitForMapKeyUpdate(root, x.name))); + const mapsCreatedPromise = Promise.all( + primitiveMapsFixtures.map((x) => waitForMapKeyUpdate(entryInstance, x.name)), + ); // create new maps and set on root await Promise.all( primitiveMapsFixtures.map((fixture) => @@ -1213,7 +1220,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'can apply MAP_CREATE with object ids object operation messages', action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; + const { root, objectsHelper, channelName, entryInstance } = ctx; const withReferencesMapKey = 'withReferencesMap'; // Objects public API allows us to check value of objects we've created based on MAP_CREATE ops @@ -1226,7 +1233,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function `Check "${withReferencesMapKey}" key doesn't exist on root before applying MAP_CREATE ops`, ).to.not.exist; - const mapCreatedPromise = waitForMapKeyUpdate(root, withReferencesMapKey); + const mapCreatedPromise = waitForMapKeyUpdate(entryInstance, withReferencesMapKey); // create map with references. need to create referenced objects first to obtain their object ids const { objectId: referencedMapObjectId } = await objectsHelper.operationRequest( channelName, @@ -1371,7 +1378,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'can apply MAP_SET with primitives object operation messages', action: async (ctx) => { - const { root, objectsHelper, channelName, helper } = ctx; + const { root, objectsHelper, channelName, helper, entryInstance } = ctx; // check root is empty before ops primitiveKeyData.forEach((keyData) => { @@ -1381,7 +1388,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ).to.not.exist; }); - const keysUpdatedPromise = Promise.all(primitiveKeyData.map((x) => waitForMapKeyUpdate(root, x.key))); + const keysUpdatedPromise = Promise.all( + primitiveKeyData.map((x) => waitForMapKeyUpdate(entryInstance, x.key)), + ); // apply MAP_SET ops await Promise.all( primitiveKeyData.map((keyData) => @@ -1414,7 +1423,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'can apply MAP_SET with object ids object operation messages', action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; + const { root, objectsHelper, channelName, entryInstance } = ctx; // check no object ids are set on root expect( @@ -1425,8 +1434,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function .not.exist; const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'keyToCounter'), - waitForMapKeyUpdate(root, 'keyToMap'), + waitForMapKeyUpdate(entryInstance, 'keyToCounter'), + waitForMapKeyUpdate(entryInstance, 'keyToMap'), ]); // create new objects and set on root await objectsHelper.createAndSetOnMap(channelName, { @@ -1541,10 +1550,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'can apply MAP_REMOVE object operation messages', action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; + const { objectsHelper, channelName, entryInstance } = ctx; const mapKey = 'map'; - const mapCreatedPromise = waitForMapKeyUpdate(root, mapKey); + const mapCreatedPromise = waitForMapKeyUpdate(entryInstance, mapKey); // create new map and set on root const { objectId: mapObjectId } = await objectsHelper.createAndSetOnMap(channelName, { mapObjectId: 'root', @@ -1558,17 +1567,17 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await mapCreatedPromise; - const map = root.get(mapKey); + const map = entryInstance.get(mapKey); // check map has expected keys before MAP_REMOVE ops expect(map.size()).to.equal( 2, `Check map at "${mapKey}" key in root has correct number of keys before MAP_REMOVE`, ); - expect(map.get('shouldStay')).to.equal( + expect(map.get('shouldStay').value()).to.equal( 'foo', `Check map at "${mapKey}" key in root has correct "shouldStay" value before MAP_REMOVE`, ); - expect(map.get('shouldDelete')).to.equal( + expect(map.get('shouldDelete').value()).to.equal( 'bar', `Check map at "${mapKey}" key in root has correct "shouldDelete" value before MAP_REMOVE`, ); @@ -1589,7 +1598,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function 1, `Check map at "${mapKey}" key in root has correct number of keys after MAP_REMOVE`, ); - expect(map.get('shouldStay')).to.equal( + expect(map.get('shouldStay').value()).to.equal( 'foo', `Check map at "${mapKey}" key in root has correct "shouldStay" value after MAP_REMOVE`, ); @@ -1727,7 +1736,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'can apply COUNTER_CREATE object operation messages', action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; + const { root, objectsHelper, channelName, entryInstance } = ctx; // Objects public API allows us to check value of objects we've created based on COUNTER_CREATE ops // if we assign those objects to another map (root for example), as there is no way to access those objects from the internal pool directly. @@ -1740,7 +1749,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function .not.exist; }); - const countersCreatedPromise = Promise.all(countersFixtures.map((x) => waitForMapKeyUpdate(root, x.name))); + const countersCreatedPromise = Promise.all( + countersFixtures.map((x) => waitForMapKeyUpdate(entryInstance, x.name)), + ); // create new counters and set on root await Promise.all( countersFixtures.map((fixture) => @@ -1848,11 +1859,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'can apply COUNTER_INC object operation messages', action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; + const { objectsHelper, channelName, entryInstance } = ctx; const counterKey = 'counter'; let expectedCounterValue = 0; - const counterCreated = waitForMapKeyUpdate(root, counterKey); + const counterCreated = waitForMapKeyUpdate(entryInstance, counterKey); // create new counter and set on root const { objectId: counterObjectId } = await objectsHelper.createAndSetOnMap(channelName, { mapObjectId: 'root', @@ -1861,7 +1872,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await counterCreated; - const counter = root.get(counterKey); + const counter = entryInstance.get(counterKey); // check counter has expected value before COUNTER_INC expect(counter.value()).to.equal( expectedCounterValue, @@ -1957,11 +1968,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'can apply OBJECT_DELETE object operation messages', action: async (ctx) => { - const { root, objectsHelper, channelName, channel } = ctx; + const { root, objectsHelper, channelName, channel, entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'map'), - waitForMapKeyUpdate(root, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), ]); // create initial objects and set on root const { objectId: mapObjectId } = await objectsHelper.createAndSetOnMap(channelName, { @@ -2108,11 +2119,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'OBJECT_DELETE triggers subscription callback with deleted data', action: async (ctx) => { - const { root, objectsHelper, channelName, channel } = ctx; + const { objectsHelper, channelName, channel, entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'map'), - waitForMapKeyUpdate(root, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), ]); // create initial objects and set on root const { objectId: mapObjectId } = await objectsHelper.createAndSetOnMap(channelName, { @@ -2132,12 +2143,15 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await objectsCreatedPromise; + const mapId = entryInstance.get('map').id(); + const counterId = entryInstance.get('counter').id(); + const mapSubPromise = new Promise((resolve, reject) => - root.get('map').subscribe((update) => { + entryInstance.get('map').subscribe((event) => { try { - expect(update?.update).to.deep.equal( - { foo: 'removed', baz: 'removed' }, - 'Check map subscription callback is called with an expected update object after OBJECT_DELETE operation', + expect(event?.message?.operation).to.deep.include( + { action: 'object.delete', objectId: mapId }, + 'Check map subscription callback is called with an expected event message after OBJECT_DELETE operation', ); resolve(); } catch (error) { @@ -2146,11 +2160,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }), ); const counterSubPromise = new Promise((resolve, reject) => - root.get('counter').subscribe((update) => { + entryInstance.get('counter').subscribe((event) => { try { - expect(update?.update).to.deep.equal( - { amount: -1 }, - 'Check counter subscription callback is called with an expected update object after OBJECT_DELETE operation', + expect(event?.message?.operation).to.deep.include( + { action: 'object.delete', objectId: counterId }, + 'Check counter subscription callback is called with an expected event message after OBJECT_DELETE operation', ); resolve(); } catch (error) { @@ -2180,9 +2194,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'OBJECT_DELETE for an object sets "tombstoneAt" from "serialTimestamp"', action: async (ctx) => { - const { root, objectsHelper, channelName, channel, helper, realtimeObject } = ctx; + const { root, objectsHelper, channelName, channel, helper, realtimeObject, entryInstance } = ctx; - const objectCreatedPromise = waitForMapKeyUpdate(root, 'object'); + const objectCreatedPromise = waitForMapKeyUpdate(entryInstance, 'object'); const { objectId } = await objectsHelper.createAndSetOnMap(channelName, { mapObjectId: 'root', key: 'object', @@ -2217,9 +2231,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'OBJECT_DELETE for an object sets "tombstoneAt" using local clock if missing "serialTimestamp"', action: async (ctx) => { - const { root, objectsHelper, channelName, channel, helper, realtimeObject } = ctx; + const { root, objectsHelper, channelName, channel, helper, realtimeObject, entryInstance } = ctx; - const objectCreatedPromise = waitForMapKeyUpdate(root, 'object'); + const objectCreatedPromise = waitForMapKeyUpdate(entryInstance, 'object'); const { objectId } = await objectsHelper.createAndSetOnMap(channelName, { mapObjectId: 'root', key: 'object', @@ -2255,9 +2269,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'MAP_SET with reference to a tombstoned object results in undefined value on key', action: async (ctx) => { - const { root, objectsHelper, channelName, channel } = ctx; + const { root, objectsHelper, channelName, channel, entryInstance } = ctx; - const objectCreatedPromise = waitForMapKeyUpdate(root, 'foo'); + const objectCreatedPromise = waitForMapKeyUpdate(entryInstance, 'foo'); // create initial objects and set on root const { objectId: counterObjectId } = await objectsHelper.createAndSetOnMap(channelName, { mapObjectId: 'root', @@ -2292,12 +2306,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'object operation message on a tombstoned object does not revive it', action: async (ctx) => { - const { root, objectsHelper, channelName, channel } = ctx; + const { root, objectsHelper, channelName, channel, entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'map1'), - waitForMapKeyUpdate(root, 'map2'), - waitForMapKeyUpdate(root, 'counter1'), + waitForMapKeyUpdate(entryInstance, 'map1'), + waitForMapKeyUpdate(entryInstance, 'map2'), + waitForMapKeyUpdate(entryInstance, 'counter1'), ]); // create initial objects and set on root const { objectId: mapId1 } = await objectsHelper.createAndSetOnMap(channelName, { @@ -2660,7 +2674,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'subsequent object operation messages are applied immediately after OBJECT_SYNC ended and buffers are applied', action: async (ctx) => { - const { root, objectsHelper, channel, channelName, helper, client } = ctx; + const { root, objectsHelper, channel, channelName, helper, client, entryInstance } = ctx; // start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages await objectsHelper.processObjectStateMessageOnChannel({ @@ -2695,7 +2709,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function syncSerial: 'serial:', }); - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'foo'); + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'foo'); // send some more operations await objectsHelper.operationRequest( channelName, @@ -2730,9 +2744,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'LiveCounter.increment sends COUNTER_INC operation', action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; + const { objectsHelper, channelName, entryInstance } = ctx; - const counterCreatedPromise = waitForMapKeyUpdate(root, 'counter'); + const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); await objectsHelper.createAndSetOnMap(channelName, { mapObjectId: 'root', key: 'counter', @@ -2740,7 +2754,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await counterCreatedPromise; - const counter = root.get('counter'); + const counter = entryInstance.get('counter'); const increments = [ 1, // value=1 10, // value=11 @@ -2773,9 +2787,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'LiveCounter.increment throws on invalid input', action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; + const { root, objectsHelper, channelName, entryInstance } = ctx; - const counterCreatedPromise = waitForMapKeyUpdate(root, 'counter'); + const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); await objectsHelper.createAndSetOnMap(channelName, { mapObjectId: 'root', key: 'counter', @@ -2840,9 +2854,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'LiveCounter.decrement sends COUNTER_INC operation', action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; + const { objectsHelper, channelName, entryInstance } = ctx; - const counterCreatedPromise = waitForMapKeyUpdate(root, 'counter'); + const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); await objectsHelper.createAndSetOnMap(channelName, { mapObjectId: 'root', key: 'counter', @@ -2850,7 +2864,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await counterCreatedPromise; - const counter = root.get('counter'); + const counter = entryInstance.get('counter'); const decrements = [ 1, // value=-1 10, // value=-11 @@ -2883,9 +2897,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'LiveCounter.decrement throws on invalid input', action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; + const { root, objectsHelper, channelName, entryInstance } = ctx; - const counterCreatedPromise = waitForMapKeyUpdate(root, 'counter'); + const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); await objectsHelper.createAndSetOnMap(channelName, { mapObjectId: 'root', key: 'counter', @@ -2950,9 +2964,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'LiveMap.set sends MAP_SET operation with primitive values', action: async (ctx) => { - const { root, helper } = ctx; + const { root, helper, entryInstance } = ctx; - const keysUpdatedPromise = Promise.all(primitiveKeyData.map((x) => waitForMapKeyUpdate(root, x.key))); + const keysUpdatedPromise = Promise.all( + primitiveKeyData.map((x) => waitForMapKeyUpdate(entryInstance, x.key)), + ); await Promise.all( primitiveKeyData.map(async (keyData) => { let value; @@ -2987,11 +3003,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'LiveMap.set sends MAP_SET operation with reference to another LiveObject', action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; + const { root, objectsHelper, channelName, entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), ]); await objectsHelper.createAndSetOnMap(channelName, { mapObjectId: 'root', @@ -3009,8 +3025,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const map = root.get('map'); const keysUpdatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter2'), - waitForMapKeyUpdate(root, 'map2'), + waitForMapKeyUpdate(entryInstance, 'counter2'), + waitForMapKeyUpdate(entryInstance, 'map2'), ]); await root.set('counter2', counter); await root.set('map2', map); @@ -3030,9 +3046,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'LiveMap.set throws on invalid input', action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; + const { root, objectsHelper, channelName, entryInstance } = ctx; - const mapCreatedPromise = waitForMapKeyUpdate(root, 'map'); + const mapCreatedPromise = waitForMapKeyUpdate(entryInstance, 'map'); await objectsHelper.createAndSetOnMap(channelName, { mapObjectId: 'root', key: 'map', @@ -3063,9 +3079,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'LiveMap.remove sends MAP_REMOVE operation', action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; + const { objectsHelper, channelName, entryInstance } = ctx; - const mapCreatedPromise = waitForMapKeyUpdate(root, 'map'); + const mapCreatedPromise = waitForMapKeyUpdate(entryInstance, 'map'); await objectsHelper.createAndSetOnMap(channelName, { mapObjectId: 'root', key: 'map', @@ -3079,7 +3095,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await mapCreatedPromise; - const map = root.get('map'); + const map = entryInstance.get('map'); const keysUpdatedPromise = Promise.all([waitForMapKeyUpdate(map, 'foo'), waitForMapKeyUpdate(map, 'bar')]); await map.remove('foo'); @@ -3089,7 +3105,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expect(map.get('foo'), 'Check can remove a key from a root via a LiveMap.remove call').to.not.exist; expect(map.get('bar'), 'Check can remove a key from a root via a LiveMap.remove call').to.not.exist; expect( - map.get('baz'), + map.get('baz').value(), 'Check non-removed keys are still present on a root after LiveMap.remove call for another keys', ).to.equal(1); }, @@ -3098,9 +3114,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'LiveMap.remove throws on invalid input', action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; + const { root, objectsHelper, channelName, entryInstance } = ctx; - const mapCreatedPromise = waitForMapKeyUpdate(root, 'map'); + const mapCreatedPromise = waitForMapKeyUpdate(entryInstance, 'map'); await objectsHelper.createAndSetOnMap(channelName, { mapObjectId: 'root', key: 'map', @@ -3134,9 +3150,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'value type created with LiveCounter.create() can be assigned to the object tree', action: async (ctx) => { - const { root } = ctx; + const { root, entryInstance } = ctx; - const counterCreatedPromise = waitForMapKeyUpdate(root, 'counter'); + const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); await root.set('counter', LiveCounter.create(1)); await counterCreatedPromise; @@ -3163,9 +3179,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'LiveCounter.create() sends COUNTER_CREATE operation', action: async (ctx) => { - const { root } = ctx; + const { root, entryInstance } = ctx; - const objectsCreatedPromise = Promise.all(countersFixtures.map((x) => waitForMapKeyUpdate(root, x.name))); + const objectsCreatedPromise = Promise.all( + countersFixtures.map((x) => waitForMapKeyUpdate(entryInstance, x.name)), + ); await Promise.all(countersFixtures.map(async (x) => root.set(x.name, LiveCounter.create(x.count)))); await objectsCreatedPromise; @@ -3248,9 +3266,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'value type created with LiveMap.create() can be assigned to the object tree', action: async (ctx) => { - const { root } = ctx; + const { root, entryInstance } = ctx; - const mapCreatedPromise = waitForMapKeyUpdate(root, 'map'); + const mapCreatedPromise = waitForMapKeyUpdate(entryInstance, 'map'); await root.set('map', LiveMap.create({ foo: 'bar' })); await mapCreatedPromise; @@ -3269,10 +3287,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'LiveMap.create() sends MAP_CREATE operation with primitive values', action: async (ctx) => { - const { root, helper } = ctx; + const { root, helper, entryInstance } = ctx; const objectsCreatedPromise = Promise.all( - primitiveMapsFixtures.map((x) => waitForMapKeyUpdate(root, x.name)), + primitiveMapsFixtures.map((x) => waitForMapKeyUpdate(entryInstance, x.name)), ); await Promise.all( primitiveMapsFixtures.map(async (mapFixture) => { @@ -3327,9 +3345,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'LiveMap.create() sends MAP_CREATE operation with reference to another LiveObject', action: async (ctx) => { - const { root } = ctx; + const { root, entryInstance } = ctx; - const objectCreatedPromise = waitForMapKeyUpdate(root, 'map'); + const objectCreatedPromise = waitForMapKeyUpdate(entryInstance, 'map'); await root.set( 'map', LiveMap.create({ @@ -3420,11 +3438,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'batch API .get method on a map returns BatchContext* wrappers for objects', action: async (ctx) => { - const { root, realtimeObject } = ctx; + const { root, realtimeObject, entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), ]); await root.set('counter', LiveCounter.create(1)); await root.set('map', LiveMap.create({ innerCounter: LiveCounter.create(1) })); @@ -3461,11 +3479,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'batch API access API methods on objects work and are synchronous', action: async (ctx) => { - const { root, realtimeObject } = ctx; + const { root, realtimeObject, entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), ]); await root.set('counter', LiveCounter.create(1)); await root.set('map', LiveMap.create({ foo: 'bar' })); @@ -3501,11 +3519,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'batch API write API methods on objects do not mutate objects inside the batch callback', action: async (ctx) => { - const { root, realtimeObject } = ctx; + const { root, realtimeObject, entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), ]); await root.set('counter', LiveCounter.create(1)); await root.set('map', LiveMap.create({ foo: 'bar' })); @@ -3547,11 +3565,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'batch API scheduled operations are applied when batch callback is finished', action: async (ctx) => { - const { root, realtimeObject } = ctx; + const { root, realtimeObject, entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), ]); await root.set('counter', LiveCounter.create(1)); await root.set('map', LiveMap.create({ foo: 'bar' })); @@ -3599,11 +3617,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'batch API scheduled operations can be canceled by throwing an error in the batch callback', action: async (ctx) => { - const { root, realtimeObject } = ctx; + const { root, realtimeObject, entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), ]); await root.set('counter', LiveCounter.create(1)); await root.set('map', LiveMap.create({ foo: 'bar' })); @@ -3645,11 +3663,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: `batch API batch context and derived objects can't be interacted with after the batch call`, action: async (ctx) => { - const { root, realtimeObject } = ctx; + const { root, realtimeObject, entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), ]); await root.set('counter', LiveCounter.create(1)); await root.set('map', LiveMap.create({ foo: 'bar' })); @@ -3684,11 +3702,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: `batch API batch context and derived objects can't be interacted with after error was thrown from batch callback`, action: async (ctx) => { - const { root, realtimeObject } = ctx; + const { root, realtimeObject, entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), ]); await root.set('counter', LiveCounter.create(1)); await root.set('map', LiveMap.create({ foo: 'bar' })); @@ -3885,10 +3903,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.path() returns correct path strings', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; - const keysUpdatedPromise = Promise.all([waitForMapKeyUpdate(root, 'nested')]); - await root.set( + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'nested'); + await entryPathObject.set( 'nested', LiveMap.create({ simple: 'value', @@ -3897,7 +3915,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function deep: LiveMap.create({ nested: 'deepValue' }), }), ); - await keysUpdatedPromise; + await keyUpdatedPromise; // Test path with .get() method expect(entryPathObject.path()).to.equal('', 'Check root PathObject has empty path'); @@ -3935,10 +3953,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.at() navigates using dot-separated paths', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { root, entryPathObject, entryInstance } = ctx; // Create nested structure - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'nested'); + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'nested'); await root.set('nested', LiveMap.create({ deepKey: 'deepValue', 'key.with.dots': 'dottedValue' })); await keyUpdatedPromise; @@ -3963,9 +3981,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject resolves complex path strings', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { root, entryPathObject, entryInstance } = ctx; - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'nested.key'); + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'nested.key'); await root.set( 'nested.key', LiveMap.create({ @@ -4001,9 +4019,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.value() returns primitive values correctly', action: async (ctx) => { - const { root, entryPathObject, helper } = ctx; + const { root, entryPathObject, helper, entryInstance } = ctx; - const keysUpdatedPromise = Promise.all(primitiveKeyData.map((x) => waitForMapKeyUpdate(root, x.key))); + const keysUpdatedPromise = Promise.all( + primitiveKeyData.map((x) => waitForMapKeyUpdate(entryInstance, x.key)), + ); await Promise.all( primitiveKeyData.map(async (keyData) => { let value; @@ -4038,9 +4058,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.value() returns LiveCounter values', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { root, entryPathObject, entryInstance } = ctx; - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); await root.set('counter', LiveCounter.create(10)); await keyUpdatedPromise; @@ -4053,11 +4073,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.instance() returns DefaultInstance for LiveMap and LiveCounter', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { root, entryPathObject, entryInstance } = ctx; const keysUpdatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'map'), - waitForMapKeyUpdate(root, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), ]); await root.set('map', LiveMap.create()); await root.set('counter', LiveCounter.create()); @@ -4076,13 +4096,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject collection methods work for LiveMap objects', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { root, entryPathObject, entryInstance } = ctx; // Set up test data const keysUpdatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'key1'), - waitForMapKeyUpdate(root, 'key2'), - waitForMapKeyUpdate(root, 'key3'), + waitForMapKeyUpdate(entryInstance, 'key1'), + waitForMapKeyUpdate(entryInstance, 'key2'), + waitForMapKeyUpdate(entryInstance, 'key3'), ]); await root.set('key1', 'value1'); await root.set('key2', 'value2'); @@ -4118,9 +4138,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.set() works for LiveMap objects with primitive values', action: async (ctx) => { - const { root, entryPathObject, helper } = ctx; + const { root, entryPathObject, helper, entryInstance } = ctx; - const keysUpdatedPromise = Promise.all(primitiveKeyData.map((x) => waitForMapKeyUpdate(root, x.key))); + const keysUpdatedPromise = Promise.all( + primitiveKeyData.map((x) => waitForMapKeyUpdate(entryInstance, x.key)), + ); await Promise.all( primitiveKeyData.map(async (keyData) => { let value; @@ -4155,9 +4177,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.set() works for LiveMap objects with LiveObject references', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { root, entryPathObject, entryInstance } = ctx; - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counterKey'); + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counterKey'); await entryPathObject.set('counterKey', LiveCounter.create(5)); await keyUpdatedPromise; @@ -4169,15 +4191,15 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.remove() works for LiveMap objects', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { root, entryPathObject, entryInstance } = ctx; - const keyAddedPromise = waitForMapKeyUpdate(root, 'keyToRemove'); + const keyAddedPromise = waitForMapKeyUpdate(entryInstance, 'keyToRemove'); await root.set('keyToRemove', 'valueToRemove'); await keyAddedPromise; expect(root.get('keyToRemove'), 'Check key exists on root').to.exist; - const keyRemovedPromise = waitForMapKeyUpdate(root, 'keyToRemove'); + const keyRemovedPromise = waitForMapKeyUpdate(entryInstance, 'keyToRemove'); await entryPathObject.remove('keyToRemove'); await keyRemovedPromise; @@ -4192,13 +4214,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.increment() and PathObject.decrement() work for LiveCounter objects', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); - await root.set('counter', LiveCounter.create(10)); + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); + await entryPathObject.set('counter', LiveCounter.create(10)); await keyUpdatedPromise; - const counter = root.get('counter'); + const counter = entryInstance.get('counter'); const counterPathObj = entryPathObject.get('counter'); let counterUpdatedPromise = waitForCounterUpdate(counter); @@ -4307,9 +4329,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject handling of operations for paths with non-collection intermediate segments', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { root, entryPathObject, entryInstance } = ctx; - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); await root.set('counter', LiveCounter.create()); await keyUpdatedPromise; @@ -4353,12 +4375,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject handling of operations on wrong underlying object type', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { root, entryPathObject, entryInstance } = ctx; const keysUpdatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'map'), - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'primitive'), + waitForMapKeyUpdate(entryInstance, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'primitive'), ]); await root.set('map', LiveMap.create()); await root.set('counter', LiveCounter.create()); @@ -4471,7 +4493,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.subscribe() receives events for nested changes with unlimited depth by default', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; let eventCount = 0; const subscriptionPromise = new Promise((resolve, reject) => { @@ -4494,17 +4516,17 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); // root level change - let keyUpdatedPromise = waitForMapKeyUpdate(root, 'nested'); + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'nested'); await entryPathObject.set('nested', LiveMap.create()); await keyUpdatedPromise; // nested change - keyUpdatedPromise = waitForMapKeyUpdate(root.get('nested'), 'child'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance.get('nested'), 'child'); await entryPathObject.get('nested').set('child', LiveMap.create()); await keyUpdatedPromise; // nested child change - keyUpdatedPromise = waitForMapKeyUpdate(root.get('nested').get('child'), 'foo'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance.get('nested').get('child'), 'foo'); await entryPathObject.get('nested').get('child').set('foo', 'bar'); await keyUpdatedPromise; @@ -4515,10 +4537,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.subscribe() with depth parameter receives expected events', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; // Create nested structure - let keyUpdatedPromise = waitForMapKeyUpdate(root, 'nested'); + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'nested'); await entryPathObject.set('nested', LiveMap.create({ counter: LiveCounter.create() })); await keyUpdatedPromise; @@ -4560,16 +4582,16 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); // Make nested changes couple of levels deep, different subscriptions should get different events - const counterUpdatedPromise = waitForCounterUpdate(root.get('nested').get('counter')); + const counterUpdatedPromise = waitForCounterUpdate(entryInstance.get('nested').get('counter')); await entryPathObject.get('nested').get('counter').increment(); await counterUpdatedPromise; - keyUpdatedPromise = waitForMapKeyUpdate(root.get('nested'), 'nestedKey'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance.get('nested'), 'nestedKey'); await entryPathObject.get('nested').set('nestedKey', 'foo'); await keyUpdatedPromise; // Now make a direct change to the root object, should trigger the callback - keyUpdatedPromise = waitForMapKeyUpdate(root, 'directKey'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'directKey'); await entryPathObject.set('directKey', 'bar'); await keyUpdatedPromise; @@ -4580,10 +4602,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.subscribe() on nested path receives events for that path and its children', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; // Create nested structure - let keyUpdatedPromise = waitForMapKeyUpdate(root, 'nested'); + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'nested'); await entryPathObject.set('nested', LiveMap.create({ counter: LiveCounter.create() })); await keyUpdatedPromise; @@ -4610,21 +4632,21 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); // root change should not trigger the subscription - keyUpdatedPromise = waitForMapKeyUpdate(root, 'foo'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'foo'); await entryPathObject.set('foo', 'bar'); await keyUpdatedPromise; // Next changes should trigger the subscription - keyUpdatedPromise = waitForMapKeyUpdate(root.get('nested'), 'foo'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance.get('nested'), 'foo'); await entryPathObject.get('nested').set('foo', 'bar'); await keyUpdatedPromise; - const counterUpdatedPromise = waitForCounterUpdate(root.get('nested').get('counter')); + const counterUpdatedPromise = waitForCounterUpdate(entryInstance.get('nested').get('counter')); await entryPathObject.get('nested').get('counter').increment(); await counterUpdatedPromise; // If object at the subscribed path is replaced, that should also trigger the subscription - keyUpdatedPromise = waitForMapKeyUpdate(root, 'nested'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'nested'); await entryPathObject.set('nested', LiveMap.create()); await keyUpdatedPromise; @@ -4635,9 +4657,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.subscribe() works with complex nested paths and escaped dots', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'escaped\\key'); + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'escaped\\key'); await entryPathObject.set('escaped\\key', LiveMap.create({ 'key.with.dots': LiveCounter.create() })); await keyUpdatedPromise; @@ -4658,7 +4680,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); }); - const counterUpdatedPromise = waitForCounterUpdate(root.get('escaped\\key').get('key.with.dots'), 'key1'); + const counterUpdatedPromise = waitForCounterUpdate(entryInstance.get('escaped\\key').get('key.with.dots')); await complexPathObject.increment(); await counterUpdatedPromise; @@ -4666,18 +4688,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, }, - { - // TODO - description: 'PathObject.subscribe() keeps subscription after underlying object is replaced', - action: async (ctx) => {}, - }, - { description: 'PathObject.subscribe() on LiveMap path receives set/remove events', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; - let keyUpdatedPromise = waitForMapKeyUpdate(root, 'map'); + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'map'); await entryPathObject.set('map', LiveMap.create()); await keyUpdatedPromise; @@ -4702,11 +4718,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); }); - keyUpdatedPromise = waitForMapKeyUpdate(root.get('map'), 'foo'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance.get('map'), 'foo'); await entryPathObject.get('map').set('foo', 'bar'); await keyUpdatedPromise; - keyUpdatedPromise = waitForMapKeyUpdate(root.get('map'), 'foo'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance.get('map'), 'foo'); await entryPathObject.get('map').remove('foo'); await keyUpdatedPromise; @@ -4717,9 +4733,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.subscribe() on LiveCounter path receives increment/decrement events', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; - let keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); await entryPathObject.set('counter', LiveCounter.create()); await keyUpdatedPromise; @@ -4759,11 +4775,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); }); - let counterUpdatedPromise = waitForCounterUpdate(root.get('counter')); + let counterUpdatedPromise = waitForCounterUpdate(entryInstance.get('counter')); await entryPathObject.get('counter').increment(); await counterUpdatedPromise; - counterUpdatedPromise = waitForCounterUpdate(root.get('counter')); + counterUpdatedPromise = waitForCounterUpdate(entryInstance.get('counter')); await entryPathObject.get('counter').decrement(); await counterUpdatedPromise; @@ -4774,9 +4790,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.subscribe() on Primitive path receives changes to the primitive value', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; - let keyUpdatedPromise = waitForMapKeyUpdate(root, 'primitive'); + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'primitive'); await entryPathObject.set('primitive', 'foo'); await keyUpdatedPromise; @@ -4804,20 +4820,20 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); // Update to other keys on root should not trigger the subscription - keyUpdatedPromise = waitForMapKeyUpdate(root, 'other'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'other'); await entryPathObject.set('other', 'bar'); await keyUpdatedPromise; // Only changes to the primitive path should trigger the subscription - keyUpdatedPromise = waitForMapKeyUpdate(root, 'primitive'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'primitive'); await entryPathObject.set('primitive', 'baz'); await keyUpdatedPromise; - keyUpdatedPromise = waitForMapKeyUpdate(root, 'primitive'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'primitive'); await entryPathObject.set('primitive', 42); await keyUpdatedPromise; - keyUpdatedPromise = waitForMapKeyUpdate(root, 'primitive'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'primitive'); await entryPathObject.set('primitive', true); await keyUpdatedPromise; @@ -4843,7 +4859,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'can unsubscribe from PathObject.subscribe() updates using returned "unsubscribe" function', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; let eventCount = 0; const { unsubscribe } = entryPathObject.subscribe(() => { @@ -4851,14 +4867,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); // Make first change - should receive event - let keyUpdatedPromise = waitForMapKeyUpdate(root, 'key1'); + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'key1'); await entryPathObject.set('key1', 'value1'); await keyUpdatedPromise; unsubscribe(); // Make second change - should NOT receive event - keyUpdatedPromise = waitForMapKeyUpdate(root, 'key2'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'key2'); await entryPathObject.set('key2', 'value2'); await keyUpdatedPromise; @@ -4869,7 +4885,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.subscribe() handles multiple subscriptions independently', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; let subscription1Events = 0; let subscription2Events = 0; @@ -4883,7 +4899,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); // Make change - both should receive event - let keyUpdatedPromise = waitForMapKeyUpdate(root, 'key1'); + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'key1'); await entryPathObject.set('key1', 'value1'); await keyUpdatedPromise; @@ -4891,7 +4907,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function unsubscribe1(); // Make another change - only second should receive event - keyUpdatedPromise = waitForMapKeyUpdate(root, 'key2'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'key2'); await entryPathObject.set('key2', 'value2'); await keyUpdatedPromise; @@ -4903,7 +4919,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.subscribe() event object provides correct PathObject instance', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; const subscriptionPromise = new Promise((resolve, reject) => { entryPathObject.subscribe((event) => { @@ -4918,7 +4934,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); }); - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'foo'); + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'foo'); await entryPathObject.set('foo', 'bar'); await keyUpdatedPromise; @@ -4929,7 +4945,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.subscribe() handles subscription listener errors gracefully', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; let goodListenerCalled = false; @@ -4943,7 +4959,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function goodListenerCalled = true; }); - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'foo'); + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'foo'); await entryPathObject.set('foo', 'bar'); await keyUpdatedPromise; @@ -4978,11 +4994,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.id() returns object ID of underlying LiveObject', action: async (ctx) => { - const { root, entryPathObject, helper } = ctx; + const { root, entryPathObject, helper, entryInstance } = ctx; const keysUpdatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'map'), - waitForMapKeyUpdate(root, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), ]); await entryPathObject.set('map', LiveMap.create()); @@ -5009,11 +5025,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.get() returns child DefaultInstance instances', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; const keysUpdatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'stringKey'), - waitForMapKeyUpdate(root, 'counterKey'), + waitForMapKeyUpdate(entryInstance, 'stringKey'), + waitForMapKeyUpdate(entryInstance, 'counterKey'), ]); await entryPathObject.set('stringKey', 'value'); await entryPathObject.set('counterKey', LiveCounter.create(42)); @@ -5036,9 +5052,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.value() returns primitive values correctly', action: async (ctx) => { - const { root, entryPathObject, helper } = ctx; + const { entryPathObject, helper, entryInstance } = ctx; - const keysUpdatedPromise = Promise.all(primitiveKeyData.map((x) => waitForMapKeyUpdate(root, x.key))); + const keysUpdatedPromise = Promise.all( + primitiveKeyData.map((x) => waitForMapKeyUpdate(entryInstance, x.key)), + ); await Promise.all( primitiveKeyData.map(async (keyData) => { let value; @@ -5073,9 +5091,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.value() returns LiveCounter values', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); await entryPathObject.set('counter', LiveCounter.create(10)); await keyUpdatedPromise; @@ -5088,13 +5106,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance collection methods work for LiveMap objects', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; // Set up test data const keysUpdatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'key1'), - waitForMapKeyUpdate(root, 'key2'), - waitForMapKeyUpdate(root, 'key3'), + waitForMapKeyUpdate(entryInstance, 'key1'), + waitForMapKeyUpdate(entryInstance, 'key2'), + waitForMapKeyUpdate(entryInstance, 'key3'), ]); await entryPathObject.set('key1', 'value1'); await entryPathObject.set('key2', 'value2'); @@ -5132,11 +5150,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.set() works for LiveMap objects with primitive values', action: async (ctx) => { - const { root, entryPathObject, helper } = ctx; + const { entryPathObject, helper, entryInstance } = ctx; const rootInstance = entryPathObject.instance(); - const keysUpdatedPromise = Promise.all(primitiveKeyData.map((x) => waitForMapKeyUpdate(root, x.key))); + const keysUpdatedPromise = Promise.all( + primitiveKeyData.map((x) => waitForMapKeyUpdate(entryInstance, x.key)), + ); await Promise.all( primitiveKeyData.map(async (keyData) => { let value; @@ -5170,11 +5190,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.set() works for LiveMap objects with LiveObject references', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { root, entryPathObject, entryInstance } = ctx; const rootInstance = entryPathObject.instance(); - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counterKey'); + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counterKey'); await rootInstance.set('counterKey', LiveCounter.create(5)); await keyUpdatedPromise; @@ -5186,17 +5206,17 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.remove() works for LiveMap objects', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { root, entryPathObject, entryInstance } = ctx; const rootInstance = entryPathObject.instance(); - const keyAddedPromise = waitForMapKeyUpdate(root, 'keyToRemove'); + const keyAddedPromise = waitForMapKeyUpdate(entryInstance, 'keyToRemove'); await entryPathObject.set('keyToRemove', 'valueToRemove'); await keyAddedPromise; expect(entryPathObject.get('keyToRemove').value(), 'Check key exists on root').to.exist; - const keyRemovedPromise = waitForMapKeyUpdate(root, 'keyToRemove'); + const keyRemovedPromise = waitForMapKeyUpdate(entryInstance, 'keyToRemove'); await rootInstance.remove('keyToRemove'); await keyRemovedPromise; @@ -5212,25 +5232,25 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.increment() and DefaultInstance.decrement() work for LiveCounter objects', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { root, entryPathObject, entryInstance } = ctx; const rootInstance = entryPathObject.instance(); - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); await entryPathObject.set('counter', LiveCounter.create(10)); await keyUpdatedPromise; - const counter = root.get('counter'); + const counter = root.get('counter'); const counterInstance = rootInstance.get('counter'); - let counterUpdatedPromise = waitForCounterUpdate(counter); + let counterUpdatedPromise = waitForCounterUpdate(counterInstance); await counterInstance.increment(5); await counterUpdatedPromise; expect(counter.value()).to.equal(15, 'Check counter incremented via DefaultInstance'); expect(counterInstance.value()).to.equal(15, 'Check DefaultInstance reflects incremented value'); - counterUpdatedPromise = waitForCounterUpdate(counter); + counterUpdatedPromise = waitForCounterUpdate(counterInstance); await counterInstance.decrement(3); await counterUpdatedPromise; @@ -5238,14 +5258,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expect(counterInstance.value()).to.equal(12, 'Check DefaultInstance reflects decremented value'); // test increment/decrement without argument (should increment/decrement by 1) - counterUpdatedPromise = waitForCounterUpdate(counter); + counterUpdatedPromise = waitForCounterUpdate(counterInstance); await counterInstance.increment(); await counterUpdatedPromise; expect(counter.value()).to.equal(13, 'Check counter incremented via DefaultInstance without argument'); expect(counterInstance.value()).to.equal(13, 'Check DefaultInstance reflects incremented value'); - counterUpdatedPromise = waitForCounterUpdate(counter); + counterUpdatedPromise = waitForCounterUpdate(counterInstance); await counterInstance.decrement(); await counterUpdatedPromise; @@ -5274,12 +5294,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance handling of operations on wrong underlying object type', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; const keysUpdatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'map'), - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'primitive'), + waitForMapKeyUpdate(entryInstance, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'primitive'), ]); await entryPathObject.set('map', LiveMap.create({ foo: 'bar' })); await entryPathObject.set('counter', LiveCounter.create()); @@ -5382,9 +5402,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.subscribe() receives events for LiveMap set/remove operations', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; - let keyUpdatedPromise = waitForMapKeyUpdate(root, 'map'); + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'map'); await entryPathObject.set('map', LiveMap.create()); await keyUpdatedPromise; @@ -5411,11 +5431,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); }); - keyUpdatedPromise = waitForMapKeyUpdate(root.get('map'), 'foo'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance.get('map'), 'foo'); await entryPathObject.get('map').set('foo', 'bar'); await keyUpdatedPromise; - keyUpdatedPromise = waitForMapKeyUpdate(root.get('map'), 'foo'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance.get('map'), 'foo'); await entryPathObject.get('map').remove('foo'); await keyUpdatedPromise; @@ -5426,9 +5446,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.subscribe() receives events for LiveCounter increment/decrement', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; - let keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); await entryPathObject.set('counter', LiveCounter.create()); await keyUpdatedPromise; @@ -5470,11 +5490,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); }); - let counterUpdatedPromise = waitForCounterUpdate(root.get('counter')); + let counterUpdatedPromise = waitForCounterUpdate(entryInstance.get('counter')); await entryPathObject.get('counter').increment(); await counterUpdatedPromise; - counterUpdatedPromise = waitForCounterUpdate(root.get('counter')); + counterUpdatedPromise = waitForCounterUpdate(entryInstance.get('counter')); await entryPathObject.get('counter').decrement(); await counterUpdatedPromise; @@ -5500,7 +5520,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'can unsubscribe from DefaultInstance.subscribe() updates using returned "unsubscribe" function', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; let eventCount = 0; const { unsubscribe } = entryPathObject.instance().subscribe(() => { @@ -5508,14 +5528,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); // Make first change - should receive event - let keyUpdatedPromise = waitForMapKeyUpdate(root, 'key1'); + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'key1'); await entryPathObject.set('key1', 'value1'); await keyUpdatedPromise; unsubscribe(); // Make second change - should NOT receive event - keyUpdatedPromise = waitForMapKeyUpdate(root, 'key2'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'key2'); await entryPathObject.set('key2', 'value2'); await keyUpdatedPromise; @@ -5526,7 +5546,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.subscribe() handles multiple subscriptions independently', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; let subscription1Events = 0; let subscription2Events = 0; @@ -5540,7 +5560,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); // Make change - both should receive event - let keyUpdatedPromise = waitForMapKeyUpdate(root, 'key1'); + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'key1'); await entryPathObject.set('key1', 'value1'); await keyUpdatedPromise; @@ -5548,7 +5568,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function unsubscribe1(); // Make another change - only second should receive event - keyUpdatedPromise = waitForMapKeyUpdate(root, 'key2'); + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'key2'); await entryPathObject.set('key2', 'value2'); await keyUpdatedPromise; @@ -5560,16 +5580,15 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.subscribe() event object provides correct DefaultInstance reference', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; - const instance = entryPathObject.instance(); const subscriptionPromise = new Promise((resolve, reject) => { - instance.subscribe((event) => { + entryInstance.subscribe((event) => { try { expect(event.object, 'Check event object exists').to.exist; expectInstanceOf(event.object, 'DefaultInstance', 'Check event object is DefaultInstance'); expect(event.object.id()).to.equal('root', 'Check event object has correct object ID'); - expect(event.object).to.equal(instance, 'Check event object is the same instance'); + expect(event.object).to.equal(entryInstance, 'Check event object is the same instance'); resolve(); } catch (error) { reject(error); @@ -5577,7 +5596,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); }); - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'foo'); + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'foo'); await entryPathObject.set('foo', 'bar'); await keyUpdatedPromise; @@ -5588,7 +5607,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.subscribe() handles subscription listener errors gracefully', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject, entryInstance } = ctx; let goodListenerCalled = false; @@ -5602,7 +5621,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function goodListenerCalled = true; }); - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'foo'); + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'foo'); await entryPathObject.set('foo', 'bar'); await keyUpdatedPromise; @@ -5637,11 +5656,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await channel.attach(); const root = await realtimeObject.get(); const entryPathObject = await realtimeObject.getPathObject(); + const entryInstance = entryPathObject.instance(); await scenario.action({ realtimeObject, root, entryPathObject, + entryInstance, objectsHelper, channelName, channel, @@ -5658,15 +5679,19 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'can subscribe to the incoming COUNTER_INC operation on a LiveCounter', action: async (ctx) => { - const { root, objectsHelper, channelName, sampleCounterKey, sampleCounterObjectId } = ctx; + const { objectsHelper, channelName, sampleCounterKey, sampleCounterObjectId, entryInstance } = ctx; - const counter = root.get(sampleCounterKey); + const counter = entryInstance.get(sampleCounterKey); const subscriptionPromise = new Promise((resolve, reject) => - counter.subscribe((update) => { + counter.subscribe((event) => { try { - expect(update?.update).to.deep.equal( - { amount: 1 }, - 'Check counter subscription callback is called with an expected update object for COUNTER_INC operation', + expect(event?.message?.operation).to.deep.include( + { + action: 'counter.inc', + objectId: counter.id(), + counterOp: { amount: 1 }, + }, + 'Check counter subscription callback is called with an expected event message for COUNTER_INC operation', ); resolve(); } catch (error) { @@ -5691,19 +5716,23 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'can subscribe to multiple incoming operations on a LiveCounter', action: async (ctx) => { - const { root, objectsHelper, channelName, sampleCounterKey, sampleCounterObjectId } = ctx; + const { objectsHelper, channelName, sampleCounterKey, sampleCounterObjectId, entryInstance } = ctx; - const counter = root.get(sampleCounterKey); + const counter = entryInstance.get(sampleCounterKey); const expectedCounterIncrements = [100, -100, Number.MAX_SAFE_INTEGER, -Number.MAX_SAFE_INTEGER]; let currentUpdateIndex = 0; const subscriptionPromise = new Promise((resolve, reject) => - counter.subscribe((update) => { + counter.subscribe((event) => { try { const expectedInc = expectedCounterIncrements[currentUpdateIndex]; - expect(update?.update).to.deep.equal( - { amount: expectedInc }, - `Check counter subscription callback is called with an expected update object for ${currentUpdateIndex + 1} times`, + expect(event?.message?.operation).to.deep.include( + { + action: 'counter.inc', + objectId: counter.id(), + counterOp: { amount: expectedInc }, + }, + `Check counter subscription callback is called with an expected event message operation for ${currentUpdateIndex + 1} times`, ); if (currentUpdateIndex === expectedCounterIncrements.length - 1) { @@ -5735,15 +5764,19 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'can subscribe to the incoming MAP_SET operation on a LiveMap', action: async (ctx) => { - const { root, objectsHelper, channelName, sampleMapKey, sampleMapObjectId } = ctx; + const { objectsHelper, channelName, sampleMapKey, sampleMapObjectId, entryInstance } = ctx; - const map = root.get(sampleMapKey); + const map = entryInstance.get(sampleMapKey); const subscriptionPromise = new Promise((resolve, reject) => - map.subscribe((update) => { + map.subscribe((event) => { try { - expect(update?.update).to.deep.equal( - { stringKey: 'updated' }, - 'Check map subscription callback is called with an expected update object for MAP_SET operation', + expect(event?.message?.operation).to.deep.include( + { + action: 'map.set', + objectId: map.id(), + mapOp: { key: 'stringKey', data: { value: 'stringValue' } }, + }, + 'Check map subscription callback is called with an expected event message for MAP_SET operation', ); resolve(); } catch (error) { @@ -5769,15 +5802,15 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'can subscribe to the incoming MAP_REMOVE operation on a LiveMap', action: async (ctx) => { - const { root, objectsHelper, channelName, sampleMapKey, sampleMapObjectId } = ctx; + const { objectsHelper, channelName, sampleMapKey, sampleMapObjectId, entryInstance } = ctx; - const map = root.get(sampleMapKey); + const map = entryInstance.get(sampleMapKey); const subscriptionPromise = new Promise((resolve, reject) => - map.subscribe((update) => { + map.subscribe((event) => { try { - expect(update?.update).to.deep.equal( - { stringKey: 'removed' }, - 'Check map subscription callback is called with an expected update object for MAP_REMOVE operation', + expect(event?.message?.operation).to.deep.include( + { action: 'map.remove', objectId: map.id(), mapOp: { key: 'stringKey' } }, + 'Check map subscription callback is called with an expected event message for MAP_REMOVE operation', ); resolve(); } catch (error) { @@ -5798,169 +5831,28 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, }, - { - allTransportsAndProtocols: true, - description: 'subscription update object contains the client metadata of the client who made the update', - action: async (ctx) => { - const { root, objectsHelper, channel, channelName, sampleMapKey, sampleCounterKey, helper } = ctx; - const publishClientId = 'publish-clientId'; - const publishClient = RealtimeWithObjects(helper, { clientId: publishClientId }); - - // get the connection ID from the publish client once connected - let publishConnectionId; - - const createCheckUpdateClientMetadataPromise = (subscribeFn, msg) => { - return new Promise((resolve, reject) => - subscribeFn((update) => { - try { - expect(update.clientId).to.equal(publishClientId, msg); - expect(update.connectionId).to.equal(publishConnectionId, msg); - resolve(); - } catch (error) { - reject(error); - } - }), - ); - }; - - // check client metadata is surfaced for mutation ops - const mutationOpsPromises = Promise.all([ - createCheckUpdateClientMetadataPromise( - (cb) => root.get(sampleCounterKey).subscribe(cb), - 'Check counter subscription callback has client metadata for COUNTER_INC operation', - ), - createCheckUpdateClientMetadataPromise( - (cb) => - root.get(sampleMapKey).subscribe((update) => { - if (update.update.foo === 'updated') { - cb(update); - } - }), - 'Check map subscription callback has client metadata for MAP_SET operation', - ), - createCheckUpdateClientMetadataPromise( - (cb) => - root.get(sampleMapKey).subscribe((update) => { - if (update.update.foo === 'removed') { - cb(update); - } - }), - 'Check map subscription callback has client metadata for MAP_REMOVE operation', - ), - ]); - - await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const publishChannel = publishClient.channels.get(channelName, channelOptionsWithObjects()); - await publishChannel.attach(); - const publishRoot = await publishChannel.object.get(); - - // capture the connection ID once the client is connected - publishConnectionId = publishClient.connection.id; - - await publishRoot.get(sampleCounterKey).increment(1); - await publishRoot.get(sampleMapKey).set('foo', 'bar'); - await publishRoot.get(sampleMapKey).remove('foo'); - }, publishClient); - - await mutationOpsPromises; - - // check client metadata is surfaced for create ops. - // first need to create non-initialized objects and then publish create ops for them - const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'nonInitializedCounter'), - waitForMapKeyUpdate(root, 'nonInitializedMap'), - ]); - - const fakeCounterObjectId = objectsHelper.fakeCounterObjectId(); - const fakeMapObjectId = objectsHelper.fakeMapObjectId(); - - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 0, 0), - siteCode: 'aaa', - state: [ - objectsHelper.mapSetOp({ - objectId: 'root', - key: 'nonInitializedCounter', - data: { objectId: fakeCounterObjectId }, - }), - ], - }); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 1, 0), - siteCode: 'aaa', - state: [ - objectsHelper.mapSetOp({ - objectId: 'root', - key: 'nonInitializedMap', - data: { objectId: fakeMapObjectId }, - }), - ], - }); - - await objectsCreatedPromise; - - const createOpsPromises = Promise.all([ - createCheckUpdateClientMetadataPromise( - (cb) => root.get('nonInitializedCounter').subscribe(cb), - 'Check counter subscription callback has client metadata for COUNTER_CREATE operation', - ), - createCheckUpdateClientMetadataPromise( - (cb) => root.get('nonInitializedMap').subscribe(cb), - 'Check map subscription callback has client metadata for MAP_CREATE operation', - ), - ]); - - // and now post create operations which will trigger subscription callbacks - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 1, 1), - siteCode: 'aaa', - clientId: publishClientId, - connectionId: publishConnectionId, - state: [objectsHelper.counterCreateOp({ objectId: fakeCounterObjectId, count: 1 })], - }); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 1, 1), - siteCode: 'aaa', - clientId: publishClientId, - connectionId: publishConnectionId, - state: [ - objectsHelper.mapCreateOp({ - objectId: fakeMapObjectId, - entries: { foo: { timeserial: lexicoTimeserial('aaa', 1, 1), data: { string: 'bar' } } }, - }), - ], - }); - - await createOpsPromises; - }, - }, - { allTransportsAndProtocols: true, description: 'can subscribe to multiple incoming operations on a LiveMap', action: async (ctx) => { - const { root, objectsHelper, channelName, sampleMapKey, sampleMapObjectId } = ctx; + const { objectsHelper, channelName, sampleMapKey, sampleMapObjectId, entryInstance } = ctx; - const map = root.get(sampleMapKey); + const map = entryInstance.get(sampleMapKey); const expectedMapUpdates = [ - { foo: 'updated' }, - { bar: 'updated' }, - { foo: 'removed' }, - { baz: 'updated' }, - { bar: 'removed' }, + { action: 'map.set', mapOp: { key: 'foo', data: { value: '1' } } }, + { action: 'map.set', mapOp: { key: 'bar', data: { value: '2' } } }, + { action: 'map.remove', mapOp: { key: 'foo' } }, + { action: 'map.set', mapOp: { key: 'baz', data: { value: '3' } } }, + { action: 'map.remove', mapOp: { key: 'bar' } }, ]; let currentUpdateIndex = 0; const subscriptionPromise = new Promise((resolve, reject) => - map.subscribe((update) => { + map.subscribe(({ message }) => { try { - expect(update?.update).to.deep.equal( + expect(message?.operation).to.deep.include( expectedMapUpdates[currentUpdateIndex], - `Check map subscription callback is called with an expected update object for ${currentUpdateIndex + 1} times`, + `Check map subscription callback is called with an expected event message operation for ${currentUpdateIndex + 1} times`, ); if (currentUpdateIndex === expectedMapUpdates.length - 1) { @@ -5979,7 +5871,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function objectsHelper.mapSetRestOp({ objectId: sampleMapObjectId, key: 'foo', - value: { string: 'something' }, + value: { string: '1' }, }), ); @@ -5988,7 +5880,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function objectsHelper.mapSetRestOp({ objectId: sampleMapObjectId, key: 'bar', - value: { string: 'something' }, + value: { string: '2' }, }), ); @@ -6005,7 +5897,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function objectsHelper.mapSetRestOp({ objectId: sampleMapObjectId, key: 'baz', - value: { string: 'something' }, + value: { string: '3' }, }), ); @@ -6021,12 +5913,115 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, }, + { + description: 'subscription event message contains the metadata of the update', + action: async (ctx) => { + const { channelName, sampleMapKey, sampleCounterKey, helper, entryPathObject, entryInstance } = ctx; + const publishClientId = 'publish-clientId'; + const publishClient = RealtimeWithObjects(helper, { clientId: publishClientId }); + + // get the connection ID from the publish client once connected + let publishConnectionId; + + const createCheckMessageMetadataPromise = (subscribeFn, msg) => { + return new Promise((resolve, reject) => + subscribeFn((event) => { + try { + expect(event.message, msg + 'object message exists').to.exist; + expect(event.message.id, msg + 'message id exists').to.exist; + expect(event.message.clientId).to.equal(publishClientId, msg + 'clientId matches expected'); + expect(event.message.connectionId).to.equal( + publishConnectionId, + msg + 'connectionId matches expected', + ); + expect(event.message.timestamp, msg + 'timestamp exists').to.exist; + expect(event.message.channel).to.equal(channelName, msg + 'channel name matches expected'); + expect(event.message.serial, msg + 'serial exists').to.exist; + expect(event.message.serialTimestamp, msg + 'serialTimestamp exists').to.exist; + expect(event.message.siteCode, msg + 'siteCode exists').to.exist; + + resolve(); + } catch (error) { + reject(error); + } + }), + ); + }; + + // check message metadata is surfaced for mutation ops + const mutationOpsPromises = Promise.all([ + // path object + createCheckMessageMetadataPromise( + (cb) => entryPathObject.get(sampleCounterKey).subscribe(cb), + 'Check event message metadata for COUNTER_INC PathObject subscriptions: ', + ), + createCheckMessageMetadataPromise( + (cb) => + entryPathObject.get(sampleMapKey).subscribe((event) => { + if (event.message.operation.action === 'map.set') { + cb(event); + } + }), + 'Check event message metadata for MAP_SET PathObject subscriptions: ', + ), + createCheckMessageMetadataPromise( + (cb) => + entryPathObject.get(sampleMapKey).subscribe((event) => { + if (event.message.operation.action === 'map.remove') { + cb(event); + } + }), + 'Check event message metadata for MAP_REMOVE PathObject subscriptions: ', + ), + + // instance + createCheckMessageMetadataPromise( + (cb) => entryInstance.get(sampleCounterKey).subscribe(cb), + 'Check event message metadata for COUNTER_INC DefaultInstance subscriptions: ', + ), + createCheckMessageMetadataPromise( + (cb) => + entryInstance.get(sampleMapKey).subscribe((event) => { + if (event.message.operation.action === 'map.set') { + cb(event); + } + }), + 'Check event message metadata for MAP_SET DefaultInstance subscriptions: ', + ), + createCheckMessageMetadataPromise( + (cb) => + entryInstance.get(sampleMapKey).subscribe((event) => { + if (event.message.operation.action === 'map.remove') { + cb(event); + } + }), + 'Check event message metadata for MAP_REMOVE DefaultInstance subscriptions: ', + ), + ]); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + const publishChannel = publishClient.channels.get(channelName, channelOptionsWithObjects()); + await publishChannel.attach(); + const publishRoot = await publishChannel.object.get(); + + // capture the connection ID once the client is connected + publishConnectionId = publishClient.connection.id; + + await publishRoot.get(sampleCounterKey).increment(1); + await publishRoot.get(sampleMapKey).set('foo', 'bar'); + await publishRoot.get(sampleMapKey).remove('foo'); + }, publishClient); + + await mutationOpsPromises; + }, + }, + { description: 'can unsubscribe from LiveCounter updates via returned "unsubscribe" callback', action: async (ctx) => { - const { root, objectsHelper, channelName, sampleCounterKey, sampleCounterObjectId } = ctx; + const { objectsHelper, channelName, sampleCounterKey, sampleCounterObjectId, entryInstance } = ctx; - const counter = root.get(sampleCounterKey); + const counter = entryInstance.get(sampleCounterKey); let callbackCalled = 0; const subscriptionPromise = new Promise((resolve) => { const { unsubscribe } = counter.subscribe(() => { @@ -6058,11 +6053,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, { + skip: true, // TODO: replace with instance/pathobject .unsubscribe() call description: 'can unsubscribe from LiveCounter updates via LiveCounter.unsubscribe() call', action: async (ctx) => { - const { root, objectsHelper, channelName, sampleCounterKey, sampleCounterObjectId } = ctx; + const { objectsHelper, channelName, sampleCounterKey, sampleCounterObjectId, entryInstance } = ctx; - const counter = root.get(sampleCounterKey); + const counter = entryInstance.get(sampleCounterKey); let callbackCalled = 0; const subscriptionPromise = new Promise((resolve) => { const listener = () => { @@ -6095,57 +6091,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, }, - { - description: 'can remove all LiveCounter update listeners via LiveCounter.unsubscribeAll() call', - action: async (ctx) => { - const { root, objectsHelper, channelName, sampleCounterKey, sampleCounterObjectId } = ctx; - - const counter = root.get(sampleCounterKey); - const callbacks = 3; - const callbacksCalled = new Array(callbacks).fill(0); - const subscriptionPromises = []; - - for (let i = 0; i < callbacks; i++) { - const promise = new Promise((resolve) => { - counter.subscribe(() => { - callbacksCalled[i]++; - resolve(); - }); - }); - subscriptionPromises.push(promise); - } - - const increments = 3; - for (let i = 0; i < increments; i++) { - const counterUpdatedPromise = waitForCounterUpdate(counter); - await objectsHelper.operationRequest( - channelName, - objectsHelper.counterIncRestOp({ - objectId: sampleCounterObjectId, - number: 1, - }), - ); - await counterUpdatedPromise; - - if (i === 0) { - // unsub all after first operation - counter.unsubscribeAll(); - } - } - - await Promise.all(subscriptionPromises); - - expect(counter.value()).to.equal(3, 'Check counter has final expected value after all increments'); - callbacksCalled.forEach((x) => expect(x).to.equal(1, 'Check subscription callbacks were called once each')); - }, - }, - { description: 'can unsubscribe from LiveMap updates via returned "unsubscribe" callback', action: async (ctx) => { - const { root, objectsHelper, channelName, sampleMapKey, sampleMapObjectId } = ctx; + const { objectsHelper, channelName, sampleMapKey, sampleMapObjectId, entryInstance } = ctx; - const map = root.get(sampleMapKey); + const map = entryInstance.get(sampleMapKey); let callbackCalled = 0; const subscriptionPromise = new Promise((resolve) => { const { unsubscribe } = map.subscribe(() => { @@ -6173,7 +6124,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await subscriptionPromise; for (let i = 0; i < mapSets; i++) { - expect(map.get(`foo-${i}`)).to.equal( + expect(map.get(`foo-${i}`).value()).to.equal( 'exists', `Check map has value for key "foo-${i}" after all map sets`, ); @@ -6183,11 +6134,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, { + skip: true, // TODO: replace with instance/pathobject .unsubscribe() call description: 'can unsubscribe from LiveMap updates via LiveMap.unsubscribe() call', action: async (ctx) => { - const { root, objectsHelper, channelName, sampleMapKey, sampleMapObjectId } = ctx; + const { objectsHelper, channelName, sampleMapKey, sampleMapObjectId, entryInstance } = ctx; - const map = root.get(sampleMapKey); + const map = entryInstance.get(sampleMapKey); let callbackCalled = 0; const subscriptionPromise = new Promise((resolve) => { const listener = () => { @@ -6225,57 +6177,6 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expect(callbackCalled).to.equal(1, 'Check subscription callback was only called once'); }, }, - - { - description: 'can remove all LiveMap update listeners via LiveMap.unsubscribeAll() call', - action: async (ctx) => { - const { root, objectsHelper, channelName, sampleMapKey, sampleMapObjectId } = ctx; - - const map = root.get(sampleMapKey); - const callbacks = 3; - const callbacksCalled = new Array(callbacks).fill(0); - const subscriptionPromises = []; - - for (let i = 0; i < callbacks; i++) { - const promise = new Promise((resolve) => { - map.subscribe(() => { - callbacksCalled[i]++; - resolve(); - }); - }); - subscriptionPromises.push(promise); - } - - const mapSets = 3; - for (let i = 0; i < mapSets; i++) { - const mapUpdatedPromise = waitForMapKeyUpdate(map, `foo-${i}`); - await objectsHelper.operationRequest( - channelName, - objectsHelper.mapSetRestOp({ - objectId: sampleMapObjectId, - key: `foo-${i}`, - value: { string: 'exists' }, - }), - ); - await mapUpdatedPromise; - - if (i === 0) { - // unsub all after first operation - map.unsubscribeAll(); - } - } - - await Promise.all(subscriptionPromises); - - for (let i = 0; i < mapSets; i++) { - expect(map.get(`foo-${i}`)).to.equal( - 'exists', - `Check map has value for key "foo-${i}" after all map sets`, - ); - } - callbacksCalled.forEach((x) => expect(x).to.equal(1, 'Check subscription callbacks were called once each')); - }, - }, ]; /** @nospec */ @@ -6288,13 +6189,15 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await channel.attach(); const root = await channel.object.get(); + const entryPathObject = await channel.object.getPathObject(); + const entryInstance = entryPathObject.instance(); const sampleMapKey = 'sampleMap'; const sampleCounterKey = 'sampleCounter'; const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, sampleMapKey), - waitForMapKeyUpdate(root, sampleCounterKey), + waitForMapKeyUpdate(entryInstance, sampleMapKey), + waitForMapKeyUpdate(entryInstance, sampleCounterKey), ]); // prepare map and counter objects for use by the scenario const { objectId: sampleMapObjectId } = await objectsHelper.createAndSetOnMap(channelName, { @@ -6311,6 +6214,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await scenario.action({ root, + entryPathObject, + entryInstance, objectsHelper, channelName, channel, @@ -6472,9 +6377,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'tombstoned map entry is removed from the LiveMap after the GC grace period', action: async (ctx) => { - const { root, objectsHelper, channelName, helper, waitForGCCycles } = ctx; + const { root, objectsHelper, channelName, helper, waitForGCCycles, entryInstance } = ctx; - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'foo'); + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'foo'); // set a key on a root await objectsHelper.operationRequest( channelName, @@ -6484,7 +6389,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expect(root.get('foo')).to.equal('bar', 'Check key "foo" exists on root after MAP_SET'); - const keyUpdatedPromise2 = waitForMapKeyUpdate(root, 'foo'); + const keyUpdatedPromise2 = waitForMapKeyUpdate(entryInstance, 'foo'); // remove the key from the root. this should tombstone the map entry and make it inaccessible to the end user, but still keep it in memory in the underlying map await objectsHelper.operationRequest( channelName, @@ -6533,6 +6438,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await channel.attach(); const root = await channel.object.get(); + const entryPathObject = await channel.object.getPathObject(); + const entryInstance = entryPathObject.instance(); helper.recordPrivateApi('read.RealtimeObject.gcGracePeriod'); const gcGracePeriodOriginal = realtimeObject.gcGracePeriod; @@ -6562,6 +6469,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await scenario.action({ client, root, + entryPathObject, + entryInstance, objectsHelper, channelName, channel, @@ -6584,7 +6493,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expect(() => counter.value()).to.throw(errorMsg); - expect(() => map.get()).to.throw(errorMsg); + expect(() => map.get('key')).to.throw(errorMsg); expect(() => map.size()).to.throw(errorMsg); expect(() => [...map.entries()]).to.throw(errorMsg); expect(() => [...map.keys()]).to.throw(errorMsg); @@ -6592,8 +6501,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function for (const obj of [map, counter]) { expect(() => obj.subscribe()).to.throw(errorMsg); - expect(() => obj.unsubscribe(() => {})).not.to.throw(); // this should not throw - expect(() => obj.unsubscribeAll()).not.to.throw(); // this should not throw + // TODO: replace with instance/pathobject .unsubscribe() call + // expect(() => obj.unsubscribe(() => {})).not.to.throw(); // this should not throw } }; @@ -6607,8 +6516,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await expectToThrowAsync(async () => map.remove(), errorMsg); for (const obj of [map, counter]) { - expect(() => obj.unsubscribe(() => {})).not.to.throw(); // this should not throw - expect(() => obj.unsubscribeAll()).not.to.throw(); // this should not throw + // TODO: replace with instance/pathobject .unsubscribe() call + // expect(() => obj.unsubscribe(() => {})).not.to.throw(); // this should not throw } }; @@ -6803,17 +6712,19 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await channel.attach(); const root = await channel.object.get(); + const entryPathObject = await channel.object.getPathObject(); + const entryInstance = entryPathObject.instance(); const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'map'), - waitForMapKeyUpdate(root, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), ]); await root.set('map', LiveMap.create()); await root.set('counter', LiveCounter.create()); await objectsCreatedPromise; - const map = root.get('map'); - const counter = root.get('counter'); + const map = entryInstance.get('map'); + const counter = entryInstance.get('counter'); await scenario.action({ realtimeObject, From 9e674190fb173fada2d80d0512a7c4d996e4384f Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Tue, 16 Sep 2025 07:39:08 +0100 Subject: [PATCH 11/45] Implement base PathObject class with access and mutation methods Adds new path-based types to ably.d.ts. The previous LiveObject types are temporarily marked as *Deprecated in ably.d.ts until they are fully converted to the new type system in the following PRs. The `RealtimeObject` entrypoint temporarily adds a `getPathObject` method to get a PathObject instance for a root and preserves the existing `.get()` method which returns a LiveMap instance directly so that corresponding tests do not fail yet. Resolves PUB-2057 --- ably.d.ts | 494 ++++++++++++-- scripts/moduleReport.ts | 1 + src/plugins/objects/pathobject.ts | 303 +++++++++ src/plugins/objects/realtimeobject.ts | 19 + .../browser/template/src/index-objects.ts | 13 +- test/realtime/objects.test.js | 604 +++++++++++++++++- 6 files changed, 1383 insertions(+), 51 deletions(-) create mode 100644 src/plugins/objects/pathobject.ts diff --git a/ably.d.ts b/ably.d.ts index 58fb46019a..2196259fb5 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -1646,7 +1646,7 @@ export type DeregisterCallback = (device: DeviceDetails, callback: StandardCallb export type ErrorCallback = (error: ErrorInfo | null) => void; /** - * A callback used in {@link LiveObject} to listen for updates to the object. + * A callback used in {@link LiveObjectDeprecated} to listen for updates to the object. * * @param update - The update object describing the changes made to the object. */ @@ -1658,7 +1658,7 @@ export type LiveObjectUpdateCallback = (update: T) => void; export type ObjectsEventCallback = () => void; /** - * The callback used for the lifecycle events emitted by {@link LiveObject}. + * The callback used for the lifecycle events emitted by {@link LiveObjectDeprecated}. */ export type LiveObjectLifecycleEventCallback = () => void; @@ -2279,7 +2279,7 @@ declare namespace LiveObjectLifecycleEvents { } /** - * Describes the events emitted by a {@link LiveObject} object. + * Describes the events emitted by a {@link LiveObjectDeprecated} object. */ export type LiveObjectLifecycleEvent = LiveObjectLifecycleEvents.DELETED; @@ -2288,7 +2288,7 @@ export type LiveObjectLifecycleEvent = LiveObjectLifecycleEvents.DELETED; */ export declare interface RealtimeObject { /** - * Retrieves the {@link LiveMap} object - the entrypoint for Objects on a channel. + * Retrieves the {@link LiveMapDeprecated} object - the entrypoint for Objects on a channel. * * A type parameter can be provided to describe the structure of the Objects on the channel. By default, it uses types from the globally defined `AblyObjectsTypes` interface. * @@ -2310,28 +2310,34 @@ export declare interface RealtimeObject { * } * ``` * - * @returns A promise which, upon success, will be fulfilled with a {@link LiveMap} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + * @returns A promise which, upon success, will be fulfilled with a {@link LiveMapDeprecated} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. * @experimental */ - get(): Promise>; + get(): Promise>; /** - * Creates a new {@link LiveMap} object instance with the provided entries. + * TODO: replace .get call with this one when we have full path object API support. + * temporary keep this and regular .get so we can have tests running against both. + */ + getPathObject>(): Promise>>; + + /** + * Creates a new {@link LiveMapDeprecated} object instance with the provided entries. * - * @param entries - The initial entries for the new {@link LiveMap} object. - * @returns A promise which, upon success, will be fulfilled with a {@link LiveMap} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + * @param entries - The initial entries for the new {@link LiveMapDeprecated} object. + * @returns A promise which, upon success, will be fulfilled with a {@link LiveMapDeprecated} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. * @experimental */ - createMap(entries?: T): Promise>; + createMap(entries?: T): Promise>; /** - * Creates a new {@link LiveCounter} object instance with the provided `count` value. + * Creates a new {@link LiveCounterDeprecated} object instance with the provided `count` value. * - * @param count - The initial value for the new {@link LiveCounter} object. - * @returns A promise which, upon success, will be fulfilled with a {@link LiveCounter} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + * @param count - The initial value for the new {@link LiveCounterDeprecated} object. + * @returns A promise which, upon success, will be fulfilled with a {@link LiveCounterDeprecated} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. * @experimental */ - createCounter(count?: number): Promise; + createCounter(count?: number): Promise; /** * Allows you to group multiple operations together and send them to the Ably service in a single channel message. @@ -2376,6 +2382,406 @@ export declare interface RealtimeObject { offAll(): void; } +/** + * Primitive types that can be stored in collection types. + * Includes JSON-serializable data so that maps and lists can hold plain JS values. + */ +export type Primitive = + | string + | number + | boolean + | Buffer + | ArrayBuffer + // JSON-serializable primitive values + | JsonArray + | JsonObject; + +/** + * Unique symbol for nominal typing within TypeScript's structural type system. + * This prevents structural compatibility between LiveObject types. + */ +declare const __livetype: unique symbol; + +// Branded interfaces that enables TypeScript to distinguish +// between LiveObject types even when they have identical structure. +// Enables PathObject to dispatch to correct method sets via conditional types. +/** + * A {@link LiveMap} is a collection type that maps string keys to values, which can be either primitive values or other LiveObjects. + */ +export interface LiveMap<_T extends Record = Record> { + /** LiveMap type symbol */ + [__livetype]: 'LiveMap'; +} + +/** + * A {@link LiveCounter} is a numeric type that supports atomic increment and decrement operations. + */ +export interface LiveCounter { + /** LiveCounter type symbol */ + [__livetype]: 'LiveCounter'; +} + +/** + * Type union that matches any LiveObject type that can be mutated, subscribed to, etc. + */ +export type LiveObject = LiveMap | LiveCounter; + +/** + * Type union that defines the base set of allowed types that can be stored in collection types. + * Describes the set of all possible values that can parameterize PathObject. + * This is the canonical union used when a narrower type cannot be inferred. + */ +export type Value = LiveObject | Primitive; + +/** + * PathObjectBase defines the set of common methods on a PathObject + * that are present regardless of the underlying type specified by the type parameter T. + */ +interface PathObjectBase<_T extends Value> { + /** + * Get the fully-qualified path string for this PathObject. + * + * Path segments with dots in them are escaped with a backslash. + * For example, a path with segments `['a', 'b.c', 'd']` will be represented as `a.b\.c.d`. + * + * @experimental + */ + path(): string; + + /** + * Get a JavaScript object representation of the object at this path. + * If the path does not resolve to any specific entry, returns `undefined`. + * + * @experimental + */ + compact(): any | undefined; +} + +/** + * PathObjectCollectionMethods defines the set of common methods on a PathObject + * that are present for any collection type, regardless of the specific underlying type. + */ +interface PathObjectCollectionMethods { + /** + * Collection types support obtaining a PathObject with a fully-qualified string path, + * which is evaluated from the current path. + * Using this method loses rich compile-time type information. + * + * @param path - A fully-qualified path string to navigate to, relative to the current path. + * @returns A {@link PathObject} for the specified path. + * @experimental + */ + at(path: string): PathObject; +} + +/** + * Defines collection methods available on a {@link LiveMapPathObject}. + */ +interface LiveMapPathObjectCollectionMethods = Record> { + /** + * Returns an iterable of key-value pairs for each entry in the map at this path. + * Each value is represented as a {@link PathObject} corresponding to its key. + * + * If the path does not resolve to a map object, returns an empty iterator. + * + * @experimental + */ + entries(): IterableIterator<[keyof T, PathObject]>; + + /** + * Returns an iterable of keys in the map at this path. + * + * If the path does not resolve to a map object, returns an empty iterator. + * + * @experimental + */ + keys(): IterableIterator; + + /** + * Returns an iterable of values in the map at this path. + * Each value is represented as a {@link PathObject}. + * + * If the path does not resolve to a map object, returns an empty iterator. + * + * @experimental + */ + values(): IterableIterator>; + + /** + * Returns the number of entries in the map at this path. + * + * If the path does not resolve to a map object, returns `undefined`. + * + * @experimental + */ + size(): number | undefined; +} + +/** + * A PathObject representing a {@link LiveMap} instance at a specific path. + * The type parameter T describes the expected structure of the map's entries. + */ +export interface LiveMapPathObject = Record> + extends PathObjectBase>, + PathObjectCollectionMethods, + LiveMapPathObjectCollectionMethods, + LiveMapOperations { + /** + * Navigate to a child path within the map by obtaining a PathObject for that path. + * The next path segment in a LiveMap is identified with a string key. + * + * @param key - A string key for the next path segment within the map. + * @returns A {@link PathObject} for the specified key. + * @experimental + */ + get(key: K): PathObject; +} + +/** + * A PathObject representing a {@link LiveCounter} instance at a specific path. + */ +export interface LiveCounterPathObject extends PathObjectBase, LiveCounterOperations { + /** + * Get the current value of the counter instance currently at this path. + * If the path does not resolve to any specific instance, returns `undefined`. + * + * @experimental + */ + value(): number | undefined; +} + +/** + * A PathObject representing a primitive value at a specific path. + */ +export interface PrimitivePathObject extends PathObjectBase { + /** + * Get the current value of the primitive currently at this path. + * If the path does not resolve to any specific entry, returns `undefined`. + * + * @experimental + */ + value(): T | undefined; +} + +/** + * AnyPathObjectCollectionMethods defines all possible methods available on a PathObject + * for the underlying collection types. + */ +interface AnyPathObjectCollectionMethods { + // LiveMap collection methods + + /** + * Returns an iterable of key-value pairs for each entry in the map, if the path resolves to a {@link LiveMap}. + * Each value is represented as a {@link PathObject} corresponding to its key. + * + * If the path does not resolve to a map object, returns an empty iterator. + * + * @experimental + */ + entries>(): IterableIterator<[keyof T, PathObject]>; + + /** + * Returns an iterable of keys in the map, if the path resolves to a {@link LiveMap}. + * + * If the path does not resolve to a map object, returns an empty iterator. + * + * @experimental + */ + keys>(): IterableIterator; + + /** + * Returns an iterable of values in the map, if the path resolves to a {@link LiveMap}. + * Each value is represented as a {@link PathObject}. + * + * If the path does not resolve to a map object, returns an empty iterator. + * + * @experimental + */ + values>(): IterableIterator>; + + /** + * Returns the number of entries in the map, if the path resolves to a {@link LiveMap}. + * + * If the path does not resolve to a map object, returns `undefined`. + * + * @experimental + */ + size(): number | undefined; +} + +/** + * Represents a PathObject when its underlying type is not known. + * Provides a unified interface that includes all possible methods. + * + * Each method supports type parameters to specify the expected + * underlying type when needed. + */ +export interface AnyPathObject + extends PathObjectBase, + PathObjectCollectionMethods, + AnyPathObjectCollectionMethods, + AnyOperations { + /** + * Navigate to a child path within the collection by obtaining a PathObject for that path. + * The next path segment in a collection is identified with a string key. + * + * @param key - A string key for the next path segment within the collection. + * @returns A {@link PathObject} for the specified key. + * @experimental + */ + get(key: string): PathObject; + + /** + * Get the current value of the LiveCounter or primitive currently at this path. + * If the path does not resolve to any specific entry, returns `undefined`. + * + * @experimental + */ + value(): T | undefined; +} + +/** + * PathObject wraps a reference to a path starting from the entrypoint object on a channel. + * The type parameter specifies the underlying type defined at that path, + * and is used to infer the correct set of methods available for that type. + */ +export type PathObject = [T] extends [LiveMap] + ? LiveMapPathObject + : [T] extends [LiveCounter] + ? LiveCounterPathObject + : [T] extends [Primitive] + ? PrimitivePathObject + : AnyPathObject; + +/** + * Defines operations available on a {@link LiveMapPathObject}. + */ +export interface LiveMapOperations = Record> { + /** + * Sends an operation to the Ably system to set a key on a map at this path to a specified value. + * + * If the underlying map instance at the path cannot be resolved when invoked, this will throw an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. TODO: point to PathObject subscribe method + * + * @param key - The key to set the value for. + * @param value - The value to assign to the key. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + set(key: K, value: T[K]): Promise; + + /** + * Sends an operation to the Ably system to remove a key from a map at this path. + * + * If the underlying map instance at the path cannot be resolved when invoked, this will throw an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. TODO: point to PathObject subscribe method + * + * @param key - The key to remove. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + remove(key: keyof T & string): Promise; +} + +/** + * Defines operations available on a {@link LiveCounterPathObject}. + */ +export interface LiveCounterOperations { + /** + * Sends an operation to the Ably system to increment the value of a counter at this path. + * + * If the underlying counter instance at the path cannot be resolved when invoked, this will throw an error. + * + * This does not modify the underlying data of the counter. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. TODO: point to PathObject subscribe method + * + * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + increment(amount?: number): Promise; + + /** + * An alias for calling {@link LiveCounterOperations.increment | increment(-amount)} + * + * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + decrement(amount?: number): Promise; +} + +/** + * Defines all possible operations available on an {@link AnyPathObject}. + */ +export interface AnyOperations { + // LiveMap operations + + /** + * Sends an operation to the Ably system to set a key on a map at this path to a specified value. + * + * If the underlying map instance at the path cannot be resolved when invoked, this will throw an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. TODO: point to PathObject subscribe method + * + * @param key - The key to set the value for. + * @param value - The value to assign to the key. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + set = Record>(key: keyof T & string, value: T[keyof T]): Promise; + + /** + * Sends an operation to the Ably system to remove a key from a map at this path. + * + * If the underlying map instance at the path cannot be resolved when invoked, this will throw an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. TODO: point to PathObject subscribe method + * + * @param key - The key to remove. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + remove = Record>(key: keyof T & string): Promise; + + // LiveCounter operations + + /** + * Sends an operation to the Ably system to increment the value of a counter at this path. + * + * If the underlying counter instance at the path cannot be resolved when invoked, this will throw an error. + * + * This does not modify the underlying data of the counter. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. TODO: point to PathObject subscribe method + * + * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + increment(amount?: number): Promise; + + /** + * An alias for calling {@link LiveCounterOperations.increment | increment(-amount)} + * + * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + decrement(amount?: number): Promise; +} + declare global { /** * A globally defined interface that allows users to define custom types for Objects. @@ -2386,16 +2792,18 @@ declare global { } /** - * Represents the type of data stored in a {@link LiveMap}. - * It maps string keys to primitive values ({@link PrimitiveObjectValue}), or other {@link LiveObject | LiveObjects}. + * Represents the type of data stored in a {@link LiveMapDeprecated}. + * It maps string keys to primitive values ({@link PrimitiveObjectValue}), or other {@link LiveObjectDeprecated | LiveObjects}. */ -export type LiveMapType = { [key: string]: PrimitiveObjectValue | LiveMap | LiveCounter | undefined }; +export type LiveMapType = { + [key: string]: PrimitiveObjectValue | LiveMapDeprecated | LiveCounterDeprecated | undefined; +}; /** - * The default type for the entrypoint {@link LiveMap} object on a channel, based on the globally defined {@link AblyObjectsTypes} interface. + * The default type for the entrypoint {@link LiveMapDeprecated} object on a channel, based on the globally defined {@link AblyObjectsTypes} interface. * * - If no custom types are provided in `AblyObjectsTypes`, defaults to an untyped map representation using the {@link LiveMapType} interface. - * - If an `object` key exists in `AblyObjectsTypes` and its type conforms to the {@link LiveMapType} interface, it is used as the type for the entrypoint {@link LiveMap} object. + * - If an `object` key exists in `AblyObjectsTypes` and its type conforms to the {@link LiveMapType} interface, it is used as the type for the entrypoint {@link LiveMapDeprecated} object. * - If the provided type in `object` key does not match {@link LiveMapType}, a type error message is returned. */ export type AblyDefaultObject = @@ -2424,7 +2832,7 @@ export declare interface OnObjectsEventResponse { */ export declare interface BatchContext { /** - * Mirrors the {@link RealtimeObject.get} method and returns a {@link BatchContextLiveMap} wrapper for the entrypoint {@link LiveMap} object on a channel. + * Mirrors the {@link RealtimeObject.get} method and returns a {@link BatchContextLiveMap} wrapper for the entrypoint {@link LiveMapDeprecated} object on a channel. * * @returns A {@link BatchContextLiveMap} object. * @experimental @@ -2433,14 +2841,14 @@ export declare interface BatchContext { } /** - * A wrapper around the {@link LiveMap} object that enables batching operations inside a {@link BatchCallback}. + * A wrapper around the {@link LiveMapDeprecated} object that enables batching operations inside a {@link BatchCallback}. */ export declare interface BatchContextLiveMap { /** - * Mirrors the {@link LiveMap.get} method and returns the value associated with a key in the map. + * Mirrors the {@link LiveMapDeprecated.get} method and returns the value associated with a key in the map. * * @param key - The key to retrieve the value for. - * @returns A {@link LiveObject}, a primitive type (string, number, boolean, JSON-serializable object or array, or binary data) or `undefined` if the key doesn't exist in a map or the associated {@link LiveObject} has been deleted. Always `undefined` if this map object is deleted. + * @returns A {@link LiveObjectDeprecated}, a primitive type (string, number, boolean, JSON-serializable object or array, or binary data) or `undefined` if the key doesn't exist in a map or the associated {@link LiveObjectDeprecated} has been deleted. Always `undefined` if this map object is deleted. * @experimental */ get(key: TKey): T[TKey] | undefined; @@ -2453,11 +2861,11 @@ export declare interface BatchContextLiveMap { size(): number; /** - * Similar to the {@link LiveMap.set} method, but instead, it adds an operation to set a key in the map with the provided value to the current batch, to be sent in a single message to the Ably service. + * Similar to the {@link LiveMapDeprecated.set} method, but instead, it adds an operation to set a key in the map with the provided value to the current batch, to be sent in a single message to the Ably service. * * This does not modify the underlying data of this object. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use the {@link LiveObject.subscribe} method. + * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. * * @param key - The key to set the value for. * @param value - The value to assign to the key. @@ -2466,11 +2874,11 @@ export declare interface BatchContextLiveMap { set(key: TKey, value: T[TKey]): void; /** - * Similar to the {@link LiveMap.remove} method, but instead, it adds an operation to remove a key from the map to the current batch, to be sent in a single message to the Ably service. + * Similar to the {@link LiveMapDeprecated.remove} method, but instead, it adds an operation to remove a key from the map to the current batch, to be sent in a single message to the Ably service. * * This does not modify the underlying data of this object. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use the {@link LiveObject.subscribe} method. + * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. * * @param key - The key to set the value for. * @experimental @@ -2479,7 +2887,7 @@ export declare interface BatchContextLiveMap { } /** - * A wrapper around the {@link LiveCounter} object that enables batching operations inside a {@link BatchCallback}. + * A wrapper around the {@link LiveCounterDeprecated} object that enables batching operations inside a {@link BatchCallback}. */ export declare interface BatchContextLiveCounter { /** @@ -2490,11 +2898,11 @@ export declare interface BatchContextLiveCounter { value(): number; /** - * Similar to the {@link LiveCounter.increment} method, but instead, it adds an operation to increment the counter value to the current batch, to be sent in a single message to the Ably service. + * Similar to the {@link LiveCounterDeprecated.increment} method, but instead, it adds an operation to increment the counter value to the current batch, to be sent in a single message to the Ably service. * * This does not modify the underlying data of this object. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use the {@link LiveObject.subscribe} method. + * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. * * @param amount - The amount by which to increase the counter value. * @experimental @@ -2515,16 +2923,16 @@ export declare interface BatchContextLiveCounter { * Conflicts in a LiveMap are automatically resolved with last-write-wins (LWW) semantics, * meaning that if two clients update the same key in the map, the update with the most recent timestamp wins. * - * Keys must be strings. Values can be another {@link LiveObject}, or a primitive type, such as a string, number, boolean, JSON-serializable object or array, or binary data (see {@link PrimitiveObjectValue}). + * Keys must be strings. Values can be another {@link LiveObjectDeprecated}, or a primitive type, such as a string, number, boolean, JSON-serializable object or array, or binary data (see {@link PrimitiveObjectValue}). */ -export declare interface LiveMap extends LiveObject> { +export declare interface LiveMapDeprecated extends LiveObjectDeprecated> { /** - * Returns the value associated with a given key. Returns `undefined` if the key doesn't exist in a map or if the associated {@link LiveObject} has been deleted. + * Returns the value associated with a given key. Returns `undefined` if the key doesn't exist in a map or if the associated {@link LiveObjectDeprecated} has been deleted. * * Always returns undefined if this map object is deleted. * * @param key - The key to retrieve the value for. - * @returns A {@link LiveObject}, a primitive type (string, number, boolean, JSON-serializable object or array, or binary data) or `undefined` if the key doesn't exist in a map or the associated {@link LiveObject} has been deleted. Always `undefined` if this map object is deleted. + * @returns A {@link LiveObjectDeprecated}, a primitive type (string, number, boolean, JSON-serializable object or array, or binary data) or `undefined` if the key doesn't exist in a map or the associated {@link LiveObjectDeprecated} has been deleted. Always `undefined` if this map object is deleted. * @experimental */ get(key: TKey): T[TKey] | undefined; @@ -2562,7 +2970,7 @@ export declare interface LiveMap extends LiveObject extends LiveObject extends LiveObject extends LiveObjectUpdate { /** @@ -2598,7 +3006,7 @@ export declare interface LiveMapUpdate extends LiveObject } /** - * Represents a primitive value that can be stored in a {@link LiveMap}. + * Represents a primitive value that can be stored in a {@link LiveMapDeprecated}. * * For binary data, the resulting type depends on the platform (`Buffer` in Node.js, `ArrayBuffer` elsewhere). */ @@ -2627,7 +3035,7 @@ export type JsonObject = { [prop: string]: Json | undefined }; /** * The `LiveCounter` class represents a counter that can be incremented or decremented and is synchronized across clients in realtime. */ -export declare interface LiveCounter extends LiveObject { +export declare interface LiveCounterDeprecated extends LiveObjectDeprecated { /** * Returns the current value of the counter. * @@ -2640,7 +3048,7 @@ export declare interface LiveCounter extends LiveObject { * * This does not modify the underlying data of this object. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use the {@link LiveObject.subscribe} method. + * To get notified when object gets updated, use the {@link LiveObjectDeprecated.subscribe} method. * * @param amount - The amount by which to increase the counter value. * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. @@ -2649,7 +3057,7 @@ export declare interface LiveCounter extends LiveObject { increment(amount: number): Promise; /** - * An alias for calling {@link LiveCounter.increment | LiveCounter.increment(-amount)} + * An alias for calling {@link LiveCounterDeprecated.increment | LiveCounter.increment(-amount)} * * @param amount - The amount by which to decrease the counter value. * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. @@ -2659,7 +3067,7 @@ export declare interface LiveCounter extends LiveObject { } /** - * Represents an update to a {@link LiveCounter} object. + * Represents an update to a {@link LiveCounterDeprecated} object. */ export declare interface LiveCounterUpdate extends LiveObjectUpdate { /** @@ -2676,7 +3084,7 @@ export declare interface LiveCounterUpdate extends LiveObjectUpdate { /** * Describes the common interface for all conflict-free data structures supported by the Objects. */ -export declare interface LiveObject { +export declare interface LiveObjectDeprecated { /** * Registers a listener that is called each time this LiveObject is updated. * diff --git a/scripts/moduleReport.ts b/scripts/moduleReport.ts index 48bf7779c2..ebe8f7805b 100644 --- a/scripts/moduleReport.ts +++ b/scripts/moduleReport.ts @@ -337,6 +337,7 @@ async function checkObjectsPluginFiles() { 'src/plugins/objects/objectid.ts', 'src/plugins/objects/objectmessage.ts', 'src/plugins/objects/objectspool.ts', + 'src/plugins/objects/pathobject.ts', 'src/plugins/objects/realtimeobject.ts', 'src/plugins/objects/syncobjectsdatapool.ts', ]); diff --git a/src/plugins/objects/pathobject.ts b/src/plugins/objects/pathobject.ts new file mode 100644 index 0000000000..e86fdc253b --- /dev/null +++ b/src/plugins/objects/pathobject.ts @@ -0,0 +1,303 @@ +import type BaseClient from 'common/lib/client/baseclient'; +import type * as API from '../../../ably'; +import type { AnyPathObject, PathObject, Primitive, Value } from '../../../ably'; +import { LiveCounter } from './livecounter'; +import { LiveMap } from './livemap'; +import { LiveObject } from './liveobject'; +import { RealtimeObject } from './realtimeobject'; + +/** + * Implementation of AnyPathObject interface. + * Provides a generic implementation that can handle any type of PathObject operations. + */ +export class DefaultPathObject implements AnyPathObject { + protected _client: BaseClient; + private _path: string[]; + + constructor( + private _realtimeObject: RealtimeObject, + private _root: LiveMap, + path: string[], + parent?: DefaultPathObject, + ) { + this._client = this._realtimeObject.getClient(); + // copy parent path array + this._path = [...(parent?._path ?? []), ...path]; + } + + /** + * Returns the fully-qualified string path that this PathObject represents. + * Path segments with dots in them are escaped with a backslash. + * For example, a path with segments `['a', 'b.c', 'd']` will be represented as `a.b\.c.d`. + */ + path(): string { + // escape dots in path segments to avoid ambiguity in the joined path + return this._escapePath(this._path).join('.'); + } + + /** + * Returns a compact representation of the object at this path + */ + compact(): any | undefined { + throw new Error('Not implemented'); + } + + /** + * Navigate to a child path within the collection by obtaining a PathObject for that path. + * The next path segment in a collection is identified with a string key. + */ + get(key: string): PathObject { + if (typeof key !== 'string') { + throw new this._client.ErrorInfo(`Path key must be a string: ${key}`, 40003, 400); + } + return new DefaultPathObject(this._realtimeObject, this._root, [key], this) as unknown as PathObject; + } + + /** + * Get a PathObject at the specified path relative to this object + */ + at(path: string): PathObject { + if (typeof path !== 'string') { + throw new this._client.ErrorInfo(`Path must be a string: ${path}`, 40003, 400); + } + + // We need to split the path on unescaped dots, i.e. dots not preceded by a backslash. + // The easy way to do this would be to use "path.split(/(?(this._realtimeObject, this._root, pathAsArray, this) as unknown as PathObject; + } + + /** + * Get the current value at this path. + * If the path does not resolve to any specific entry, returns `undefined`. + */ + value(): U | undefined { + try { + const resolved = this._resolvePath(this._path); + + if (resolved instanceof LiveObject) { + if (resolved instanceof LiveCounter) { + return resolved.value() as U; + } + + // can't resolve value for other live object types + return undefined; + } else if ( + this._client.Platform.BufferUtils.isBuffer(resolved) || + typeof resolved === 'string' || + typeof resolved === 'number' || + typeof resolved === 'boolean' || + typeof resolved === 'object' || + resolved === null + ) { + // primitive type - return it + return resolved as U; + } else { + this._client.Logger.logAction( + this._client.logger, + this._client.Logger.LOG_MAJOR, + 'PathObject.value()', + `unexpected value type at path, resolving to undefined; path=${this._escapePath(this._path).join('.')}`, + ); + // unknown type - return undefined + return undefined; + } + } catch (error) { + if (this._client.Utils.isErrorInfoOrPartialErrorInfo(error) && error.code === 92005) { + // ignore path resolution errors and return undefined + return undefined; + } + // rethrow everything else + throw error; + } + } + + /** + * Returns an iterator of [key, value] pairs for LiveMap entries + */ + *entries>(): IterableIterator<[keyof U, PathObject]> { + try { + const resolved = this._resolvePath(this._path); + if (!(resolved instanceof LiveMap)) { + // return empty iterator for non-LiveMap objects + return; + } + + for (const [key, _] of resolved.entries()) { + const value = new DefaultPathObject(this._realtimeObject, this._root, [key], this) as unknown as PathObject< + U[keyof U] + >; + yield [key, value]; + } + } catch (error) { + if (this._client.Utils.isErrorInfoOrPartialErrorInfo(error) && error.code === 92005) { + // ignore path resolution errors and return empty iterator + return; + } + // rethrow everything else + throw error; + } + } + + /** + * Returns an iterator of keys for LiveMap entries + */ + *keys>(): IterableIterator { + for (const [key] of this.entries()) { + yield key; + } + } + + /** + * Returns an iterator of PathObject values for LiveMap entries + */ + *values>(): IterableIterator> { + for (const [_, value] of this.entries()) { + yield value; + } + } + + /** + * Returns the size of the collection at this path + */ + size(): number | undefined { + try { + const resolved = this._resolvePath(this._path); + if (!(resolved instanceof LiveMap)) { + // can't return size for non-LiveMap objects + return undefined; + } + + return resolved.size(); + } catch (error) { + if (this._client.Utils.isErrorInfoOrPartialErrorInfo(error) && error.code === 92005) { + // ignore path resolution errors and return undefined + return undefined; + } + // rethrow everything else + throw error; + } + } + + set = Record>( + key: keyof T & string, + value: T[keyof T], + ): Promise { + const resolved = this._resolvePath(this._path); + if (!(resolved instanceof LiveMap)) { + throw new this._client.ErrorInfo( + `Cannot set a key on a non-LiveMap object at path: ${this._escapePath(this._path).join('.')}`, + 92007, + 400, + ); + } + + return resolved.set(key, value); + } + + remove = Record>(key: keyof T & string): Promise { + const resolved = this._resolvePath(this._path); + if (!(resolved instanceof LiveMap)) { + throw new this._client.ErrorInfo( + `Cannot remove a key from a non-LiveMap object at path: ${this._escapePath(this._path).join('.')}`, + 92007, + 400, + ); + } + + return resolved.remove(key); + } + + increment(amount?: number): Promise { + const resolved = this._resolvePath(this._path); + if (!(resolved instanceof LiveCounter)) { + throw new this._client.ErrorInfo( + `Cannot increment a non-LiveCounter object at path: ${this._escapePath(this._path).join('.')}`, + 92007, + 400, + ); + } + + return resolved.increment(amount ?? 1); + } + + decrement(amount?: number): Promise { + const resolved = this._resolvePath(this._path); + if (!(resolved instanceof LiveCounter)) { + throw new this._client.ErrorInfo( + `Cannot decrement a non-LiveCounter object at path: ${this._escapePath(this._path).join('.')}`, + 92007, + 400, + ); + } + + return resolved.decrement(amount ?? 1); + } + + private _resolvePath(path: string[]): Value { + // TODO: remove type assertion when internal LiveMap is updated to support new path based type system + let current: Value = this._root as unknown as API.LiveMap; + + for (let i = 0; i < path.length; i++) { + const segment = path[i]; + + if (!(current instanceof LiveMap)) { + throw new this._client.ErrorInfo( + `Cannot resolve path segment '${segment}' on non-collection type at path: ${this._escapePath(path.slice(0, i)).join('.')}`, + 92005, + 400, + ); + } + + const next: Value | undefined = current.get(segment); + + if (next === undefined) { + throw new this._client.ErrorInfo( + `Could not resolve value at path: ${this._escapePath(path.slice(0, i + 1)).join('.')}`, + 92005, + 400, + ); + } + + current = next; + } + + return current; + } + + private _escapePath(path: string[]): string[] { + return path.map((x) => x.replace(/\./g, '\\.')); + } +} diff --git a/src/plugins/objects/realtimeobject.ts b/src/plugins/objects/realtimeobject.ts index f6283295b6..c54b49f0e0 100644 --- a/src/plugins/objects/realtimeobject.ts +++ b/src/plugins/objects/realtimeobject.ts @@ -9,6 +9,7 @@ import { LiveMap } from './livemap'; import { LiveObject, LiveObjectUpdate, LiveObjectUpdateNoop } from './liveobject'; import { ObjectMessage, ObjectOperationAction } from './objectmessage'; import { ObjectsPool, ROOT_OBJECT_ID } from './objectspool'; +import { DefaultPathObject } from './pathobject'; import { SyncObjectsDataPool } from './syncobjectsdatapool'; export enum ObjectsEvent { @@ -90,6 +91,24 @@ export class RealtimeObject { return this._objectsPool.get(ROOT_OBJECT_ID) as LiveMap; // RTO1d } + // TODO: replace .get call with this one when we have full path object API support. + async getPathObject>(): Promise>> { + this.throwIfInvalidAccessApiConfiguration(); // RTO1a, RTO1b + + // if we're not synced yet, wait for sync sequence to finish before returning root + if (this._state !== ObjectsState.synced) { + await this._eventEmitterInternal.once(ObjectsEvent.synced); // RTO1c + } + + const pathObject = new DefaultPathObject>( + this, + // TODO: fix LiveMap when internal LiveMap is updated to support new path based type system + this._objectsPool.get(ROOT_OBJECT_ID) as LiveMap, + [], + ); + return pathObject; + } + /** * Provides access to the synchronous write API for Objects that can be used to batch multiple operations together in a single channel message. */ diff --git a/test/package/browser/template/src/index-objects.ts b/test/package/browser/template/src/index-objects.ts index 76d2905604..6cdc7755ad 100644 --- a/test/package/browser/template/src/index-objects.ts +++ b/test/package/browser/template/src/index-objects.ts @@ -1,4 +1,5 @@ import * as Ably from 'ably'; +import { LiveCounterDeprecated, LiveMapDeprecated } from 'ably'; import Objects from 'ably/objects'; import { createSandboxAblyAPIKey } from './sandbox'; @@ -13,13 +14,13 @@ type MyCustomObject = { stringKey: string; booleanKey: boolean; couldBeUndefined?: string; - mapKey: Ably.LiveMap<{ + mapKey: LiveMapDeprecated<{ foo: 'bar'; - nestedMap?: Ably.LiveMap<{ + nestedMap?: LiveMapDeprecated<{ baz: 'qux'; }>; }>; - counterKey: Ably.LiveCounter; + counterKey: LiveCounterDeprecated; }; declare global { @@ -42,7 +43,7 @@ globalThis.testAblyPackage = async function () { await channel.attach(); // expect entrypoint to be a LiveMap instance with Objects types defined via the global AblyObjectsTypes interface // also checks that we can refer to the Objects types exported from 'ably' by referencing a LiveMap interface - const myObject: Ably.LiveMap = await channel.object.get(); + const myObject: LiveMapDeprecated = await channel.object.get(); // check entrypoint has expected LiveMap TypeScript type methods const size: number = myObject.size(); @@ -56,7 +57,7 @@ globalThis.testAblyPackage = async function () { const aBoolean: boolean | undefined = myObject.get('booleanKey'); const userProvidedUndefined: string | undefined = myObject.get('couldBeUndefined'); // objects on the entrypoint: - const counter: Ably.LiveCounter | undefined = myObject.get('counterKey'); + const counter: LiveCounterDeprecated | undefined = myObject.get('counterKey'); const map: AblyObjectsTypes['object']['mapKey'] | undefined = myObject.get('mapKey'); // check string literal types works // need to use nullish coalescing as we didn't actually create any data on the entrypoint object, @@ -89,6 +90,6 @@ globalThis.testAblyPackage = async function () { counterSubscribeResponse?.unsubscribe(); // check can provide custom types for the object.get() method, ignoring global AblyObjectsTypes interface - const explicitObjectType: Ably.LiveMap = await channel.object.get(); + const explicitObjectType: LiveMapDeprecated = await channel.object.get(); const someOtherKey: string | undefined = explicitObjectType.get('someOtherKey'); }; diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js index aabb649be8..38217efffc 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/objects.test.js @@ -73,12 +73,15 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function return `${paddedTimestamp}-${paddedCounter}@${seriesId}` + (paddedIndex ? `:${paddedIndex}` : ''); } - async function expectToThrowAsync(fn, errorStr) { + async function expectToThrowAsync(fn, errorStr, conditions) { + const { withCode } = conditions ?? {}; + let savedError; try { await fn(); } catch (error) { expect(error.message).to.have.string(errorStr); + if (withCode != null) expect(error.code).to.equal(withCode); savedError = error; } expect(savedError, 'Expected async function to throw an error').to.exist; @@ -449,6 +452,31 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function } } + function checkKeyDataOnPathObject({ helper, key, keyData, mapObj, pathObject, msg }) { + // should check that both mapObj and pathObject return the same value for the key + // and it matches the expected value from keyData + const compareMsg = `Check PathObject and LiveMap have the same value for "${keyData.key}" key`; + + if (keyData.data.bytes != null) { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); + + expect( + BufferUtils.areBuffersEqual(pathObject.get(key).value(), BufferUtils.base64Decode(keyData.data.bytes)), + msg, + ).to.be.true; + expect(BufferUtils.areBuffersEqual(pathObject.get(key).value(), mapObj.get(key)), compareMsg).to.be.true; + } else if (keyData.data.json != null) { + const expectedObject = JSON.parse(keyData.data.json); + expect(pathObject.get(key).value()).to.deep.equal(expectedObject, msg); + expect(pathObject.get(key).value()).to.deep.equal(mapObj.get(key), compareMsg); + } else { + const expectedValue = keyData.data.string ?? keyData.data.number ?? keyData.data.boolean; + expect(pathObject.get(key).value()).to.equal(expectedValue, msg); + expect(pathObject.get(key).value()).to.equal(mapObj.get(key), compareMsg); + } + } + const primitiveKeyData = [ { key: 'stringKey', data: { string: 'stringValue' } }, { key: 'emptyStringKey', data: { string: '' } }, @@ -3967,6 +3995,575 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, ]; + const pathObjectScenarios = [ + { + description: 'RealtimeObject.getPathObject() returns PathObject instance', + action: async (ctx) => { + const { entryPathObject } = ctx; + + expect(entryPathObject, 'Check entry path object exists').to.exist; + expectInstanceOf(entryPathObject, 'DefaultPathObject', 'entrypoint should be of DefaultPathObject type'); + }, + }, + + { + description: 'PathObject.get() returns child PathObject instances', + action: async (ctx) => { + const { entryPathObject } = ctx; + + const stringPathObj = entryPathObject.get('stringKey'); + const numberPathObj = entryPathObject.get('numberKey'); + + expect(stringPathObj, 'Check string PathObject exists').to.exist; + expect(stringPathObj.path()).to.equal('stringKey', 'Check string PathObject has correct path'); + + expect(numberPathObj, 'Check number PathObject exists').to.exist; + expect(numberPathObj.path()).to.equal('numberKey', 'Check number PathObject has correct path'); + }, + }, + + { + description: 'PathObject.path() returns correct path strings', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject } = ctx; + + const keysUpdatedPromise = Promise.all([waitForMapKeyUpdate(root, 'nested')]); + const nestedMap = await realtimeObject.createMap({ + simple: 'value', + deep: await realtimeObject.createMap({ nested: 'deepValue' }), + 'key.with.dots': 'dottedValue', + 'key\\escaped': 'escapedValue', + }); + await root.set('nested', nestedMap); + await keysUpdatedPromise; + + // Test path with .get() method + expect(entryPathObject.path()).to.equal('', 'Check root PathObject has empty path'); + expect(entryPathObject.get('nested').path()).to.equal('nested', 'Check simple child path'); + expect(entryPathObject.get('nested').get('simple').path()).to.equal( + 'nested.simple', + 'Check nested path via get()', + ); + expect(entryPathObject.get('nested').get('deep').get('nested').path()).to.equal( + 'nested.deep.nested', + 'Check complex nested path', + ); + expect(entryPathObject.get('nested').get('key.with.dots').path()).to.equal( + 'nested.key\\.with\\.dots', + 'Check path with dots in key name is properly escaped', + ); + expect(entryPathObject.get('nested').get('key\\escaped').path()).to.equal( + 'nested.key\\escaped', + 'Check path with escaped symbols', + ); + + // Test path with .at() method + expect(entryPathObject.at('nested.simple').path()).to.equal('nested.simple', 'Check nested path via at()'); + expect(entryPathObject.at('nested.key\\.with\\.dots').path()).to.equal( + 'nested.key\\.with\\.dots', + 'Check path via at() method with dots in key name is properly escaped', + ); + expect(entryPathObject.at('nested.key\\escaped').path()).to.equal( + 'nested.key\\escaped', + 'Check path via at() method with escaped symbols', + ); + }, + }, + + { + description: 'PathObject.at() navigates using dot-separated paths', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject } = ctx; + + // Create nested structure + const keyUpdatedPromise = waitForMapKeyUpdate(root, 'nested'); + const nestedMap = await realtimeObject.createMap({ deepKey: 'deepValue', 'key.with.dots': 'dottedValue' }); + await root.set('nested', nestedMap); + await keyUpdatedPromise; + + const nestedPathObj = entryPathObject.at('nested.deepKey'); + expect(nestedPathObj, 'Check nested PathObject exists').to.exist; + expect(nestedPathObj.path()).to.equal('nested.deepKey', 'Check nested PathObject has correct path'); + expect(nestedPathObj.value()).to.equal('deepValue', 'Check nested PathObject has correct value'); + + const nestedPathWithDotsObj = entryPathObject.at('nested.key\\.with\\.dots'); + expect(nestedPathWithDotsObj, 'Check nested PathObject with dots in path exists').to.exist; + expect(nestedPathWithDotsObj.path()).to.equal( + 'nested.key\\.with\\.dots', + 'Check nested PathObject with dots in path has correct path', + ); + expect(nestedPathWithDotsObj.value()).to.equal( + 'dottedValue', + 'Check nested PathObject with dots in path has correct value', + ); + }, + }, + + { + description: 'PathObject resolves complex path strings', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(root, 'nested.key'); + const nestedMap = await realtimeObject.createMap({ + 'key.with.dots.and\\escaped\\characters': 'nestedValue', + }); + await root.set('nested.key', nestedMap); + await keyUpdatedPromise; + + // Test complex path via chaining .get() + const pathObjViaGetChain = entryPathObject.get('nested.key').get('key.with.dots.and\\escaped\\characters'); + expect(pathObjViaGetChain.value()).to.equal( + 'nestedValue', + 'Check PathObject resolves value for a complex path via chain of get() calls', + ); + expect(pathObjViaGetChain.path()).to.equal( + 'nested\\.key.key\\.with\\.dots\\.and\\escaped\\characters', + 'Check PathObject returns correct path for a complex path via chain of get() calls', + ); + + // Test complex path via .at() + const pathObjViaAt = entryPathObject.at('nested\\.key.key\\.with\\.dots\\.and\\escaped\\characters'); + expect(pathObjViaAt.value()).to.equal( + 'nestedValue', + 'Check PathObject resolves value for a complex path via at() call', + ); + expect(pathObjViaAt.path()).to.equal( + 'nested\\.key.key\\.with\\.dots\\.and\\escaped\\characters', + 'Check PathObject returns correct path for a complex path via at() call', + ); + }, + }, + + { + description: 'PathObject.value() returns primitive values correctly', + action: async (ctx) => { + const { root, entryPathObject, helper } = ctx; + + const keysUpdatedPromise = Promise.all(primitiveKeyData.map((x) => waitForMapKeyUpdate(root, x.key))); + await Promise.all( + primitiveKeyData.map(async (keyData) => { + let value; + if (keyData.data.bytes != null) { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + value = BufferUtils.base64Decode(keyData.data.bytes); + } else if (keyData.data.json != null) { + value = JSON.parse(keyData.data.json); + } else { + value = keyData.data.number ?? keyData.data.string ?? keyData.data.boolean; + } + + await root.set(keyData.key, value); + }), + ); + await keysUpdatedPromise; + + // check PathObject returns primitive values correctly + primitiveKeyData.forEach((keyData) => { + checkKeyDataOnPathObject({ + helper, + key: keyData.key, + keyData, + mapObj: root, + pathObject: entryPathObject, + msg: `Check PathObject returns correct value for "${keyData.key}" key after LiveMap.set call`, + }); + }); + }, + }, + + { + description: 'PathObject.value() returns LiveCounter values', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); + const counter = await realtimeObject.createCounter(10); + await root.set('counter', counter); + await keyUpdatedPromise; + + const counterPathObj = entryPathObject.get('counter'); + + expect(counterPathObj.value()).to.equal(10, 'Check counter value is returned correctly'); + }, + }, + + { + description: 'PathObject.value() returns undefined for LiveMap objects', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(root, 'map'); + const map = await realtimeObject.createMap({ key: 'map' }); + await root.set('map', map); + await keyUpdatedPromise; + + const mapPathObj = entryPathObject.get('map'); + + expect(mapPathObj.value(), 'Check PathObject.value() for a LiveMap object returns undefined').to.be + .undefined; + }, + }, + + { + description: 'PathObject collection methods work for LiveMap objects', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + // Set up test data + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(root, 'key1'), + waitForMapKeyUpdate(root, 'key2'), + waitForMapKeyUpdate(root, 'key3'), + ]); + await root.set('key1', 'value1'); + await root.set('key2', 'value2'); + await root.set('key3', 'value3'); + await keysUpdatedPromise; + + // Test size + expect(entryPathObject.size()).to.equal(3, 'Check PathObject size'); + + // Test keys + const keys = [...entryPathObject.keys()]; + expect(keys).to.have.members(['key1', 'key2', 'key3'], 'Check PathObject keys'); + + // Test entries + const entries = [...entryPathObject.entries()]; + expect(entries).to.have.lengthOf(3, 'Check PathObject entries length'); + + const entryKeys = entries.map(([key]) => key); + expect(entryKeys).to.have.members(['key1', 'key2', 'key3'], 'Check entry keys'); + + const entryValues = entries.map(([key, pathObj]) => pathObj.value()); + expect(entryValues).to.have.members(['value1', 'value2', 'value3'], 'Check PathObject entries values'); + + // Test values + const values = [...entryPathObject.values()]; + expect(values).to.have.lengthOf(3, 'Check PathObject values length'); + + const valueValues = values.map((pathObj) => pathObj.value()); + expect(valueValues).to.have.members(['value1', 'value2', 'value3'], 'Check PathObject values'); + }, + }, + + { + description: 'PathObject.set() works for LiveMap objects with primitive values', + action: async (ctx) => { + const { root, entryPathObject, helper } = ctx; + + const keysUpdatedPromise = Promise.all(primitiveKeyData.map((x) => waitForMapKeyUpdate(root, x.key))); + await Promise.all( + primitiveKeyData.map(async (keyData) => { + let value; + if (keyData.data.bytes != null) { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + value = BufferUtils.base64Decode(keyData.data.bytes); + } else if (keyData.data.json != null) { + value = JSON.parse(keyData.data.json); + } else { + value = keyData.data.number ?? keyData.data.string ?? keyData.data.boolean; + } + + await entryPathObject.set(keyData.key, value); + }), + ); + await keysUpdatedPromise; + + // check primitive values were set correctly via PathObject + primitiveKeyData.forEach((keyData) => { + checkKeyDataOnPathObject({ + helper, + key: keyData.key, + keyData, + mapObj: root, + pathObject: entryPathObject, + msg: `Check PathObject returns correct value for "${keyData.key}" key after PathObject.set call`, + }); + }); + }, + }, + + { + description: 'PathObject.set() works for LiveMap objects with LiveObject references', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counterKey'); + const counter = await realtimeObject.createCounter(5); + await entryPathObject.set('counterKey', counter); + await keyUpdatedPromise; + + expect(root.get('counterKey')).to.equal(counter, 'Check counter object was set via PathObject'); + expect(entryPathObject.get('counterKey').value()).to.equal(5, 'Check PathObject reflects counter value'); + }, + }, + + { + description: 'PathObject.remove() works for LiveMap objects', + action: async (ctx) => { + const { root, entryPathObject } = ctx; + + const keyAddedPromise = waitForMapKeyUpdate(root, 'keyToRemove'); + await root.set('keyToRemove', 'valueToRemove'); + await keyAddedPromise; + + expect(root.get('keyToRemove'), 'Check key exists on root').to.exist; + + const keyRemovedPromise = waitForMapKeyUpdate(root, 'keyToRemove'); + await entryPathObject.remove('keyToRemove'); + await keyRemovedPromise; + + expect(root.get('keyToRemove'), 'Check key on root is removed after PathObject.remove()').to.be.undefined; + expect( + entryPathObject.get('keyToRemove').value(), + 'Check value for path is undefined after PathObject.remove()', + ).to.be.undefined; + }, + }, + + { + description: 'PathObject.increment() and PathObject.decrement() work for LiveCounter objects', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); + const counter = await realtimeObject.createCounter(10); + await root.set('counter', counter); + await keyUpdatedPromise; + + const counterPathObj = entryPathObject.get('counter'); + + let counterUpdatedPromise = waitForCounterUpdate(counter); + await counterPathObj.increment(5); + await counterUpdatedPromise; + + expect(counter.value()).to.equal(15, 'Check counter incremented via PathObject'); + expect(counterPathObj.value()).to.equal(15, 'Check PathObject reflects incremented value'); + + counterUpdatedPromise = waitForCounterUpdate(counter); + await counterPathObj.decrement(3); + await counterUpdatedPromise; + + expect(counter.value()).to.equal(12, 'Check counter decremented via PathObject'); + expect(counterPathObj.value()).to.equal(12, 'Check PathObject reflects decremented value'); + + // test increment/decrement without argument (should increment/decrement by 1) + counterUpdatedPromise = waitForCounterUpdate(counter); + await counterPathObj.increment(); + await counterUpdatedPromise; + + expect(counter.value()).to.equal(13, 'Check counter incremented via PathObject without argument'); + expect(counterPathObj.value()).to.equal(13, 'Check PathObject reflects incremented value'); + + counterUpdatedPromise = waitForCounterUpdate(counter); + await counterPathObj.decrement(); + await counterUpdatedPromise; + + expect(counter.value()).to.equal(12, 'Check counter decremented via PathObject without argument'); + expect(counterPathObj.value()).to.equal(12, 'Check PathObject reflects decremented value'); + }, + }, + + { + description: 'PathObject.get() throws error for non-string keys', + action: async (ctx) => { + const { entryPathObject } = ctx; + + expect(() => entryPathObject.get()).to.throw('Path key must be a string'); + expect(() => entryPathObject.get(null)).to.throw('Path key must be a string'); + expect(() => entryPathObject.get(123)).to.throw('Path key must be a string'); + expect(() => entryPathObject.get(BigInt(1))).to.throw('Path key must be a string'); + expect(() => entryPathObject.get(true)).to.throw('Path key must be a string'); + expect(() => entryPathObject.get({})).to.throw('Path key must be a string'); + expect(() => entryPathObject.get([])).to.throw('Path key must be a string'); + }, + }, + + { + description: 'PathObject.at() throws error for non-string paths', + action: async (ctx) => { + const { entryPathObject } = ctx; + + expect(() => entryPathObject.at()).to.throw('Path must be a string'); + expect(() => entryPathObject.at(null)).to.throw('Path must be a string'); + expect(() => entryPathObject.at(123)).to.throw('Path must be a string'); + expect(() => entryPathObject.at(BigInt(1))).to.throw('Path must be a string'); + expect(() => entryPathObject.at(true)).to.throw('Path must be a string'); + expect(() => entryPathObject.at({})).to.throw('Path must be a string'); + expect(() => entryPathObject.at([])).to.throw('Path must be a string'); + }, + }, + + { + description: 'PathObject handling of operations on non-existent paths', + action: async (ctx) => { + const { entryPathObject } = ctx; + + const nonExistentPathObj = entryPathObject.at('non.existent.path'); + const errorMsg = 'Could not resolve value at path'; + + // Next operations should not throw and silently handle non-existent path + expect(nonExistentPathObj.value(), 'Check PathObject.value() for non-existent path returns undefined').to.be + .undefined; + expect([...nonExistentPathObj.entries()]).to.deep.equal( + [], + 'Check PathObject.entries() for non-existent path returns empty iterator', + ); + expect([...nonExistentPathObj.keys()]).to.deep.equal( + [], + 'Check PathObject.keys() for non-existent path returns empty iterator', + ); + expect([...nonExistentPathObj.values()]).to.deep.equal( + [], + 'Check PathObject.values() for non-existent path returns empty iterator', + ); + expect(nonExistentPathObj.size(), 'Check PathObject.size() for non-existent path returns undefined').to.be + .undefined; + + // Next operations should throw due to path resolution failure + await expectToThrowAsync(async () => nonExistentPathObj.set('key', 'value'), errorMsg, { withCode: 92005 }); + await expectToThrowAsync(async () => nonExistentPathObj.remove('key'), errorMsg, { + withCode: 92005, + }); + await expectToThrowAsync(async () => nonExistentPathObj.increment(), errorMsg, { + withCode: 92005, + }); + await expectToThrowAsync(async () => nonExistentPathObj.decrement(), errorMsg, { + withCode: 92005, + }); + }, + }, + + { + description: 'PathObject handling of operations for paths with non-collection intermediate segments', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(root, 'counter'); + const counter = await realtimeObject.createCounter(); + await root.set('counter', counter); + await keyUpdatedPromise; + + const wrongTypePathObj = entryPathObject.at('counter.nested.path'); + const errorMsg = `Cannot resolve path segment 'nested' on non-collection type at path`; + + // Next operations should not throw and silently handle incorrect path + expect(wrongTypePathObj.value(), 'Check PathObject.value() for non-collection path returns undefined').to.be + .undefined; + expect([...wrongTypePathObj.entries()]).to.deep.equal( + [], + 'Check PathObject.entries() for non-collection path returns empty iterator', + ); + expect([...wrongTypePathObj.keys()]).to.deep.equal( + [], + 'Check PathObject.keys() for non-collection path returns empty iterator', + ); + expect([...wrongTypePathObj.values()]).to.deep.equal( + [], + 'Check PathObject.values() for non-collection path returns empty iterator', + ); + expect(wrongTypePathObj.size(), 'Check PathObject.size() for non-collection path returns undefined').to.be + .undefined; + + // These should throw due to path resolution failure + await expectToThrowAsync(async () => wrongTypePathObj.set('key', 'value'), errorMsg, { withCode: 92005 }); + await expectToThrowAsync(async () => wrongTypePathObj.remove('key'), errorMsg, { + withCode: 92005, + }); + await expectToThrowAsync(async () => wrongTypePathObj.increment(), errorMsg, { + withCode: 92005, + }); + await expectToThrowAsync(async () => wrongTypePathObj.decrement(), errorMsg, { + withCode: 92005, + }); + }, + }, + + { + description: 'PathObject handling of operations on wrong underlying object type', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject } = ctx; + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(root, 'map'), + waitForMapKeyUpdate(root, 'counter'), + waitForMapKeyUpdate(root, 'primitive'), + ]); + const map = await realtimeObject.createMap(); + const counter = await realtimeObject.createCounter(5); + await root.set('map', map); + await root.set('counter', counter); + await root.set('primitive', 'value'); + await keysUpdatedPromise; + + const mapPathObj = entryPathObject.get('map'); + const counterPathObj = entryPathObject.get('counter'); + const primitivePathObj = entryPathObject.get('primitive'); + + // collection methods silently handle incorrect underlying type + expect([...primitivePathObj.entries()]).to.deep.equal( + [], + 'Check PathObject.entries() for wrong underlying object type returns empty iterator', + ); + expect([...primitivePathObj.keys()]).to.deep.equal( + [], + 'Check PathObject.keys() for wrong underlying object type returns empty iterator', + ); + expect([...primitivePathObj.values()]).to.deep.equal( + [], + 'Check PathObject.values() for wrong underlying object type returns empty iterator', + ); + expect( + primitivePathObj.size(), + 'Check PathObject.size() for wrong underlying object type returns undefined', + ).to.be.undefined; + + // map mutation methods throw errors for non-LiveMap objects + await expectToThrowAsync( + async () => primitivePathObj.set('key', 'value'), + 'Cannot set a key on a non-LiveMap object', + { withCode: 92007 }, + ); + await expectToThrowAsync( + async () => counterPathObj.set('key', 'value'), + 'Cannot set a key on a non-LiveMap object', + { withCode: 92007 }, + ); + + await expectToThrowAsync( + async () => primitivePathObj.remove('key'), + 'Cannot remove a key from a non-LiveMap object', + { withCode: 92007 }, + ); + await expectToThrowAsync( + async () => counterPathObj.remove('key'), + 'Cannot remove a key from a non-LiveMap object', + { withCode: 92007 }, + ); + + // PathObject counter methods throw errors for non-LiveCounter objects + await expectToThrowAsync( + async () => primitivePathObj.increment(), + 'Cannot increment a non-LiveCounter object', + { withCode: 92007 }, + ); + await expectToThrowAsync(async () => mapPathObj.increment(), 'Cannot increment a non-LiveCounter object', { + withCode: 92007, + }); + + await expectToThrowAsync( + async () => primitivePathObj.decrement(), + 'Cannot decrement a non-LiveCounter object', + { withCode: 92007 }, + ); + await expectToThrowAsync(async () => mapPathObj.decrement(), 'Cannot decrement a non-LiveCounter object', { + withCode: 92007, + }); + }, + }, + ]; + /** @nospec */ forScenarios( this, @@ -3976,6 +4573,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ...applyOperationsDuringSyncScenarios, ...writeApiScenarios, ...liveMapEnumerationScenarios, + ...pathObjectScenarios, ], async function (helper, scenario, clientOptions, channelName) { const objectsHelper = new ObjectsHelper(helper); @@ -3986,11 +4584,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const realtimeObject = channel.object; await channel.attach(); - const root = await channel.object.get(); + const root = await realtimeObject.get(); + const entryPathObject = await realtimeObject.getPathObject(); await scenario.action({ realtimeObject, root, + entryPathObject, objectsHelper, channelName, channel, From 9833a3b9bddd1cf5375259765d4e752343fc4163 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Fri, 10 Oct 2025 07:45:45 +0100 Subject: [PATCH 12/45] Return `undefined` from Instance access methods on wrong underlying type This makes LiveObjects Instance methods to be in line with PathObject methods handling of wrong underlying type, as discussed in [1]. Type/behavior overview: - id(): may return `undefined` (when Instance is from .get() call that returned a primitive instance) - compact(): is defined for all Instance types so can be non-nullable. See method implementation in [2] - get(): may return `undefined` when not a map - entries()/keys()/values()/size(): may return `undefined` when not a map - value(): may return `undefined` when not a counter or a primitive [1] https://github.com/ably/ably-js/pull/2091#discussion_r2416087641 [2] https://github.com/ably/ably-js/pull/2098 --- ably.d.ts | 78 +++++--- src/plugins/objects/instance.ts | 30 ++- src/plugins/objects/pathobject.ts | 8 +- test/realtime/objects.test.js | 313 +++++++++++++----------------- 4 files changed, 215 insertions(+), 214 deletions(-) diff --git a/ably.d.ts b/ably.d.ts index 89d6522b23..2a17d5bb51 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2690,8 +2690,9 @@ export interface LiveMapOperations = Record = Record { /** * Get the object ID of this instance. * + * If the underlying instance at runtime is not a {@link LiveObject}, returns `undefined`. + * * @experimental */ - id(): string; + id(): string | undefined; /** - * Get a JavaScript object representation of the instance. + * Get a JavaScript object representation of this instance. * * @experimental */ @@ -2868,6 +2876,8 @@ interface LiveMapInstanceCollectionMethods = Rec * Returns an iterable of key-value pairs for each entry in the map. * Each value is represented as an {@link Instance} corresponding to its key. * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * * @experimental */ entries(): IterableIterator<[keyof T, Instance]>; @@ -2875,6 +2885,8 @@ interface LiveMapInstanceCollectionMethods = Rec /** * Returns an iterable of keys in the map. * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * * @experimental */ keys(): IterableIterator; @@ -2883,6 +2895,8 @@ interface LiveMapInstanceCollectionMethods = Rec * Returns an iterable of values in the map. * Each value is represented as an {@link Instance}. * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * * @experimental */ values(): IterableIterator>; @@ -2890,9 +2904,11 @@ interface LiveMapInstanceCollectionMethods = Rec /** * Returns the number of entries in the map. * + * If the underlying instance at runtime is not a map, returns `undefined`. + * * @experimental */ - size(): number; + size(): number | undefined; } /** @@ -2926,10 +2942,11 @@ export interface LiveMapInstance = Record, LiveCounterOperations { /** * Get the current value of the counter instance. + * If the underlying instance at runtime is not a counter, returns `undefined`. * * @experimental */ - value(): number; + value(): number | undefined; } /** @@ -2941,9 +2958,11 @@ export interface PrimitiveInstance { * Get the primitive value represented by this instance. * This reflects the value at the corresponding key in the collection at the time this instance was obtained. * + * If the underlying instance at runtime is not a primitive value, returns `undefined`. + * * @experimental */ - value(): T; + value(): T | undefined; } /** @@ -2957,6 +2976,8 @@ interface AnyInstanceCollectionMethods { * Returns an iterable of key-value pairs for each entry in the map. * Each value is represented as an {@link Instance} corresponding to its key. * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * * @experimental */ entries>(): IterableIterator<[keyof T, Instance]>; @@ -2964,14 +2985,18 @@ interface AnyInstanceCollectionMethods { /** * Returns an iterable of keys in the map. * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * * @experimental */ keys>(): IterableIterator; /** - * Returns an iterable of values in the map,. + * Returns an iterable of values in the map. * Each value is represented as a {@link Instance}. * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * * @experimental */ values>(): IterableIterator>; @@ -2979,9 +3004,11 @@ interface AnyInstanceCollectionMethods { /** * Returns the number of entries in the map. * + * If the underlying instance at runtime is not a map, returns `undefined`. + * * @experimental */ - size(): number; + size(): number | undefined; } /** @@ -2996,21 +3023,26 @@ export interface AnyInstance extends InstanceBase, AnyInstan * Navigate to a child entry within the collection by obtaining the {@link Instance} at that entry. * The entry in a collection is identified with a string key. * + * Returns `undefined` if: + * - The underlying instance at runtime is not a collection object. + * - The specified key does not exist in the collection. + * - The referenced {@link LiveObject} has been deleted. + * - This collection object itself has been deleted. + * * @param key - The key to get the child entry for. - * @returns The {@link Instance} for the specified key, or `undefined` if no such entry exists. + * @returns An {@link Instance} representing either a {@link LiveObject} or a primitive value (string, number, boolean, JSON-serializable object or array, or binary data), or `undefined` if the underlying instance at runtime is not a collection object, the key does not exist, the referenced {@link LiveObject} has been deleted, or this collection object itself has been deleted. * @experimental */ get(key: string): Instance | undefined; /** - * Get the current value of the underlying LiveCounter or primitive. + * Get the current value of the underlying counter or primitive. * * If the underlying value is a primitive, this reflects the value at the corresponding key * in the collection at the time this instance was obtained. * - * Returns `undefined` if the underlying object is of a different type. + * If the underlying instance at runtime is neither a counter nor a primitive value, returns `undefined`. * - * @returns The current value or `undefined` if not applicable to the underlying object. * @experimental */ value(): T | undefined; diff --git a/src/plugins/objects/instance.ts b/src/plugins/objects/instance.ts index 02ec9c6fd6..80dabd3602 100644 --- a/src/plugins/objects/instance.ts +++ b/src/plugins/objects/instance.ts @@ -15,9 +15,10 @@ export class DefaultInstance implements AnyInstance { this._client = this._realtimeObject.getClient(); } - id(): string { + id(): string | undefined { if (!(this._value instanceof LiveObject)) { - throw new this._client.ErrorInfo('Cannot get object ID for a non-LiveObject instance', 40000, 400); + // no id exists for non-LiveObject types + return undefined; } return this._value.getObjectId(); } @@ -28,7 +29,8 @@ export class DefaultInstance implements AnyInstance { get(key: string): Instance | undefined { if (!(this._value instanceof LiveMap)) { - throw new this._client.ErrorInfo('Cannot get value for a key from a non-LiveMap instance', 40000, 400); + // can't get a key from a non-LiveMap type + return undefined; } if (typeof key !== 'string') { @@ -61,6 +63,12 @@ export class DefaultInstance implements AnyInstance { // primitive type - return it return this._value as unknown as U; } else { + this._client.Logger.logAction( + this._client.logger, + this._client.Logger.LOG_MAJOR, + 'DefaultInstance.value()', + `unexpected value type for instance, resolving to undefined; value=${this._value}`, + ); // unknown type - return undefined return undefined; } @@ -68,7 +76,8 @@ export class DefaultInstance implements AnyInstance { *entries>(): IterableIterator<[keyof U, Instance]> { if (!(this._value instanceof LiveMap)) { - throw new this._client.ErrorInfo('Cannot iterate entries on a non-LiveMap instance', 40000, 400); + // return empty iterator for non-LiveMap objects + return; } for (const [key, value] of this._value.entries()) { @@ -89,9 +98,10 @@ export class DefaultInstance implements AnyInstance { } } - size(): number { + size(): number | undefined { if (!(this._value instanceof LiveMap)) { - throw new this._client.ErrorInfo('Cannot get size of a non-LiveMap instance', 40000, 400); + // can't return size for non-LiveMap objects + return undefined; } return this._value.size(); } @@ -101,28 +111,28 @@ export class DefaultInstance implements AnyInstance { value: U[keyof U], ): Promise { if (!(this._value instanceof LiveMap)) { - throw new this._client.ErrorInfo('Cannot set a key on a non-LiveMap instance', 40000, 400); + throw new this._client.ErrorInfo('Cannot set a key on a non-LiveMap instance', 92007, 400); } return this._value.set(key, value); } remove = Record>(key: keyof U & string): Promise { if (!(this._value instanceof LiveMap)) { - throw new this._client.ErrorInfo('Cannot remove a key from a non-LiveMap instance', 40000, 400); + throw new this._client.ErrorInfo('Cannot remove a key from a non-LiveMap instance', 92007, 400); } return this._value.remove(key); } increment(amount?: number | undefined): Promise { if (!(this._value instanceof LiveCounter)) { - throw new this._client.ErrorInfo('Cannot increment a non-LiveCounter instance', 40000, 400); + throw new this._client.ErrorInfo('Cannot increment a non-LiveCounter instance', 92007, 400); } return this._value.increment(amount ?? 1); } decrement(amount?: number | undefined): Promise { if (!(this._value instanceof LiveCounter)) { - throw new this._client.ErrorInfo('Cannot decrement a non-LiveCounter instance', 40000, 400); + throw new this._client.ErrorInfo('Cannot decrement a non-LiveCounter instance', 92007, 400); } return this._value.decrement(amount ?? 1); } diff --git a/src/plugins/objects/pathobject.ts b/src/plugins/objects/pathobject.ts index a5d49b8804..88d2bf9147 100644 --- a/src/plugins/objects/pathobject.ts +++ b/src/plugins/objects/pathobject.ts @@ -154,14 +154,14 @@ export class DefaultPathObject implements AnyPathObject return new DefaultInstance(this._realtimeObject, value); } - // return undefined for primitive values + // return undefined for non live objects return undefined; } catch (error) { - if (this._client.Utils.isErrorInfoOrPartialErrorInfo(error)) { - // ignore ErrorInfos indicating path resolution failure and return undefined + if (this._client.Utils.isErrorInfoOrPartialErrorInfo(error) && error.code === 92005) { + // ignore path resolution errors and return undefined return undefined; } - // otherwise rethrow unexpected errors + // rethrow everything else throw error; } } diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js index c344d2282a..9a5702a638 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/objects.test.js @@ -4211,19 +4211,25 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, { - description: 'PathObject.value() returns undefined for LiveMap objects', + description: 'PathObject.instance() returns DefaultInstance for LiveMap and LiveCounter', action: async (ctx) => { const { root, realtimeObject, entryPathObject } = ctx; - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'map'); - const map = await realtimeObject.createMap({ key: 'map' }); - await root.set('map', map); - await keyUpdatedPromise; + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(root, 'map'), + waitForMapKeyUpdate(root, 'counter'), + ]); + await root.set('map', await realtimeObject.createMap()); + await root.set('counter', await realtimeObject.createCounter()); + await keysUpdatedPromise; - const mapPathObj = entryPathObject.get('map'); + const counterInstance = entryPathObject.get('counter').instance(); + expect(counterInstance, 'Check instance exists for counter path').to.exist; + expectInstanceOf(counterInstance, 'DefaultInstance', 'Check counter instance is DefaultInstance'); - expect(mapPathObj.value(), 'Check PathObject.value() for a LiveMap object returns undefined').to.be - .undefined; + const mapInstance = entryPathObject.get('map').instance(); + expect(mapInstance, 'Check instance exists for map path').to.exist; + expectInstanceOf(mapInstance, 'DefaultInstance', 'Check map instance is DefaultInstance'); }, }, @@ -4428,6 +4434,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // Next operations should not throw and silently handle non-existent path expect(nonExistentPathObj.value(), 'Check PathObject.value() for non-existent path returns undefined').to.be .undefined; + expect(nonExistentPathObj.instance(), 'Check PathObject.instance() for non-existent path returns undefined') + .to.be.undefined; expect([...nonExistentPathObj.entries()]).to.deep.equal( [], 'Check PathObject.entries() for non-existent path returns empty iterator', @@ -4473,6 +4481,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // Next operations should not throw and silently handle incorrect path expect(wrongTypePathObj.value(), 'Check PathObject.value() for non-collection path returns undefined').to.be .undefined; + expect(wrongTypePathObj.instance(), 'Check PathObject.instance() for non-collection path returns undefined') + .to.be.undefined; expect([...wrongTypePathObj.entries()]).to.deep.equal( [], 'Check PathObject.entries() for non-collection path returns empty iterator', @@ -4523,7 +4533,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const counterPathObj = entryPathObject.get('counter'); const primitivePathObj = entryPathObject.get('primitive'); - // collection methods silently handle incorrect underlying type + // next methods silently handle incorrect underlying type + expect(mapPathObj.value(), 'Check PathObject.value() for wrong underlying object type returns undefined').to + .be.undefined; + expect( + primitivePathObj.instance(), + 'Check PathObject.instance() for wrong underlying object type returns undefined', + ).to.be.undefined; expect([...primitivePathObj.entries()]).to.deep.equal( [], 'Check PathObject.entries() for wrong underlying object type returns empty iterator', @@ -4544,100 +4560,52 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // map mutation methods throw errors for non-LiveMap objects await expectToThrowAsync( async () => primitivePathObj.set('key', 'value'), - 'Cannot set a key on a non-LiveMap object', + 'Cannot set a key on a non-LiveMap object at path', { withCode: 92007 }, ); await expectToThrowAsync( async () => counterPathObj.set('key', 'value'), - 'Cannot set a key on a non-LiveMap object', + 'Cannot set a key on a non-LiveMap object at path', { withCode: 92007 }, ); await expectToThrowAsync( async () => primitivePathObj.remove('key'), - 'Cannot remove a key from a non-LiveMap object', + 'Cannot remove a key from a non-LiveMap object at path', { withCode: 92007 }, ); await expectToThrowAsync( async () => counterPathObj.remove('key'), - 'Cannot remove a key from a non-LiveMap object', + 'Cannot remove a key from a non-LiveMap object at path', { withCode: 92007 }, ); - // PathObject counter methods throw errors for non-LiveCounter objects + // counter mutation methods throw errors for non-LiveCounter objects await expectToThrowAsync( async () => primitivePathObj.increment(), - 'Cannot increment a non-LiveCounter object', + 'Cannot increment a non-LiveCounter object at path', { withCode: 92007 }, ); - await expectToThrowAsync(async () => mapPathObj.increment(), 'Cannot increment a non-LiveCounter object', { - withCode: 92007, - }); + await expectToThrowAsync( + async () => mapPathObj.increment(), + 'Cannot increment a non-LiveCounter object at path', + { + withCode: 92007, + }, + ); await expectToThrowAsync( async () => primitivePathObj.decrement(), - 'Cannot decrement a non-LiveCounter object', + 'Cannot decrement a non-LiveCounter object at path', { withCode: 92007 }, ); - await expectToThrowAsync(async () => mapPathObj.decrement(), 'Cannot decrement a non-LiveCounter object', { - withCode: 92007, - }); - }, - }, - - { - description: 'PathObject.instance() returns DefaultInstance for LiveMap and LiveCounter', - action: async (ctx) => { - const { root, realtimeObject, entryPathObject } = ctx; - - const keysUpdatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'map'), - waitForMapKeyUpdate(root, 'counter'), - ]); - await root.set('map', await realtimeObject.createMap()); - await root.set('counter', await realtimeObject.createCounter()); - await keysUpdatedPromise; - - const counterInstance = entryPathObject.get('counter').instance(); - expect(counterInstance, 'Check instance exists for counter path').to.exist; - expectInstanceOf(counterInstance, 'DefaultInstance', 'Check counter instance is DefaultInstance'); - - const mapInstance = entryPathObject.get('map').instance(); - expect(mapInstance, 'Check instance exists for map path').to.exist; - expectInstanceOf(mapInstance, 'DefaultInstance', 'Check map instance is DefaultInstance'); - }, - }, - - { - description: 'PathObject.instance() returns undefined for primitive values and non-existent paths', - action: async (ctx) => { - const { root, helper, entryPathObject } = ctx; - - const keysUpdatedPromise = Promise.all(primitiveKeyData.map((x) => waitForMapKeyUpdate(root, x.key))); - await Promise.all( - primitiveKeyData.map(async (keyData) => { - let value; - if (keyData.data.bytes != null) { - helper.recordPrivateApi('call.BufferUtils.base64Decode'); - value = BufferUtils.base64Decode(keyData.data.bytes); - } else if (keyData.data.json != null) { - value = JSON.parse(keyData.data.json); - } else { - value = keyData.data.number ?? keyData.data.string ?? keyData.data.boolean; - } - - await entryPathObject.set(keyData.key, value); - }), + await expectToThrowAsync( + async () => mapPathObj.decrement(), + 'Cannot decrement a non-LiveCounter object at path', + { + withCode: 92007, + }, ); - await keysUpdatedPromise; - - primitiveKeyData.forEach((keyData) => { - const primitiveInstance = entryPathObject.get(keyData.key).instance(); - expect(primitiveInstance, 'Check instance is undefined for primitive path').to.be.undefined; - }); - - expect(entryPathObject.at('non.existing.path').instance(), 'Check instance is undefined for primitive path') - .to.be.undefined; }, }, ]; @@ -4699,23 +4667,6 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, }, - { - description: 'DefaultInstance.get() throws error for non-string keys', - action: async (ctx) => { - const { entryPathObject } = ctx; - - const rootInstance = entryPathObject.instance(); - - expect(() => rootInstance.get()).to.throw('Key must be a string'); - expect(() => rootInstance.get(null)).to.throw('Key must be a string'); - expect(() => rootInstance.get(123)).to.throw('Key must be a string'); - expect(() => rootInstance.get(BigInt(1))).to.throw('Key must be a string'); - expect(() => rootInstance.get(true)).to.throw('Key must be a string'); - expect(() => rootInstance.get({})).to.throw('Key must be a string'); - expect(() => rootInstance.get([])).to.throw('Key must be a string'); - }, - }, - { description: 'DefaultInstance.value() returns primitive values correctly', action: async (ctx) => { @@ -4769,23 +4720,6 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, }, - { - description: 'DefaultInstance.value() returns undefined for LiveMap objects', - action: async (ctx) => { - const { root, realtimeObject, entryPathObject } = ctx; - - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'map'); - const map = await realtimeObject.createMap({ key: 'map' }); - await entryPathObject.set('map', map); - await keyUpdatedPromise; - - const mapInstance = entryPathObject.get('map').instance(); - - expect(mapInstance.value(), 'Check DefaultInstance.value() for a LiveMap object returns undefined').to.be - .undefined; - }, - }, - { description: 'DefaultInstance collection methods work for LiveMap objects', action: async (ctx) => { @@ -4830,25 +4764,6 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, }, - { - description: 'DefaultInstance collection methods throw errors for non-LiveMap objects', - action: async (ctx) => { - const { root, entryPathObject } = ctx; - - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'primitive'); - await entryPathObject.set('primitive', 'value'); - await keyUpdatedPromise; - - const rootInstance = entryPathObject.instance(); - const primitiveInstance = rootInstance.get('primitive'); - - expect(() => primitiveInstance.size()).to.throw('Cannot get size of a non-LiveMap instance'); - expect(() => [...primitiveInstance.entries()]).to.throw('Cannot iterate entries on a non-LiveMap instance'); - expect(() => [...primitiveInstance.values()]).to.throw('Cannot iterate entries on a non-LiveMap instance'); - expect(() => [...primitiveInstance.keys()]).to.throw('Cannot iterate entries on a non-LiveMap instance'); - }, - }, - { description: 'DefaultInstance.set() works for LiveMap objects with primitive values', action: async (ctx) => { @@ -4930,44 +4845,6 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, }, - { - description: 'DefaultInstance map mutation methods throw errors for non-LiveMap objects', - action: async (ctx) => { - const { root, realtimeObject, entryPathObject } = ctx; - - const rootInstance = entryPathObject.instance(); - - const keysUpdatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'primitive'), - ]); - const counter = await realtimeObject.createCounter(5); - await entryPathObject.set('counter', counter); - await entryPathObject.set('primitive', 'value'); - await keysUpdatedPromise; - - const primitiveInstance = rootInstance.get('primitive'); - await expectToThrowAsync( - async () => primitiveInstance.set('key', 'value'), - 'Cannot set a key on a non-LiveMap instance', - ); - await expectToThrowAsync( - async () => primitiveInstance.remove('key'), - 'Cannot remove a key from a non-LiveMap instance', - ); - - const counterInstance = rootInstance.get('counter'); - await expectToThrowAsync( - async () => counterInstance.set('key', 'value'), - 'Cannot set a key on a non-LiveMap instance', - ); - await expectToThrowAsync( - async () => counterInstance.remove('key'), - 'Cannot remove a key from a non-LiveMap instance', - ); - }, - }, - { description: 'DefaultInstance.increment() and DefaultInstance.decrement() work for LiveCounter objects', action: async (ctx) => { @@ -5014,39 +4891,121 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, { - description: 'DefaultInstance counter methods throw errors for non-LiveCounter objects', + description: 'DefaultInstance.get() throws error for non-string keys', action: async (ctx) => { - const { root, realtimeObject, entryPathObject } = ctx; + const { entryPathObject } = ctx; const rootInstance = entryPathObject.instance(); + expect(() => rootInstance.get()).to.throw('Key must be a string'); + expect(() => rootInstance.get(null)).to.throw('Key must be a string'); + expect(() => rootInstance.get(123)).to.throw('Key must be a string'); + expect(() => rootInstance.get(BigInt(1))).to.throw('Key must be a string'); + expect(() => rootInstance.get(true)).to.throw('Key must be a string'); + expect(() => rootInstance.get({})).to.throw('Key must be a string'); + expect(() => rootInstance.get([])).to.throw('Key must be a string'); + }, + }, + + { + description: 'DefaultInstance handling of operations on wrong underlying object type', + action: async (ctx) => { + const { root, realtimeObject, entryPathObject } = ctx; + const keysUpdatedPromise = Promise.all([ waitForMapKeyUpdate(root, 'map'), + waitForMapKeyUpdate(root, 'counter'), waitForMapKeyUpdate(root, 'primitive'), ]); - const map = await realtimeObject.createMap(); + const map = await realtimeObject.createMap({ foo: 'bar' }); + const counter = await realtimeObject.createCounter(5); await entryPathObject.set('map', map); + await entryPathObject.set('counter', counter); await entryPathObject.set('primitive', 'value'); await keysUpdatedPromise; - const primitiveInstance = rootInstance.get('primitive'); + const mapInstance = entryPathObject.get('map').instance(); + const counterInstance = entryPathObject.get('counter').instance(); + const primitiveInstance = mapInstance.get('foo'); + + // next methods silently handle incorrect underlying type + expect( + primitiveInstance.id(), + 'Check DefaultInstance.id() for wrong underlying object type returns undefined', + ).to.be.undefined; + expect( + primitiveInstance.get('foo'), + 'Check DefaultInstance.get() for wrong underlying object type returns undefined', + ).to.be.undefined; + expect( + mapInstance.value(), + 'Check DefaultInstance.value() for wrong underlying object type returns undefined', + ).to.be.undefined; + expect([...primitiveInstance.entries()]).to.deep.equal( + [], + 'Check DefaultInstance.entries() for wrong underlying object type returns empty iterator', + ); + expect([...primitiveInstance.keys()]).to.deep.equal( + [], + 'Check DefaultInstance.keys() for wrong underlying object type returns empty iterator', + ); + expect([...primitiveInstance.values()]).to.deep.equal( + [], + 'Check DefaultInstance.values() for wrong underlying object type returns empty iterator', + ); + expect( + primitiveInstance.size(), + 'Check DefaultInstance.size() for wrong underlying object type returns undefined', + ).to.be.undefined; + + // map mutation methods throw errors for non-LiveMap objects await expectToThrowAsync( - async () => primitiveInstance.increment(), - 'Cannot increment a non-LiveCounter instance', + async () => primitiveInstance.set('key', 'value'), + 'Cannot set a key on a non-LiveMap instance', + { withCode: 92007 }, ); await expectToThrowAsync( - async () => primitiveInstance.decrement(), - 'Cannot decrement a non-LiveCounter instance', + async () => counterInstance.set('key', 'value'), + 'Cannot set a key on a non-LiveMap instance', + { withCode: 92007 }, ); - const mapInstance = rootInstance.get('map'); + await expectToThrowAsync( + async () => primitiveInstance.remove('key'), + 'Cannot remove a key from a non-LiveMap instance', + { withCode: 92007 }, + ); + await expectToThrowAsync( + async () => counterInstance.remove('key'), + 'Cannot remove a key from a non-LiveMap instance', + { withCode: 92007 }, + ); + + // counter mutation methods throw errors for non-LiveCounter objects + await expectToThrowAsync( + async () => primitiveInstance.increment(), + 'Cannot increment a non-LiveCounter instance', + { withCode: 92007 }, + ); await expectToThrowAsync( async () => mapInstance.increment(), 'Cannot increment a non-LiveCounter instance', + { + withCode: 92007, + }, + ); + + await expectToThrowAsync( + async () => primitiveInstance.decrement(), + 'Cannot decrement a non-LiveCounter instance', + { withCode: 92007 }, ); await expectToThrowAsync( async () => mapInstance.decrement(), 'Cannot decrement a non-LiveCounter instance', + { + withCode: 92007, + }, ); }, }, From 0dc0ce636fa952e23c83e5fa20cf890737a90142 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Fri, 10 Oct 2025 09:18:17 +0100 Subject: [PATCH 13/45] Remove RealtimeObject.createMap() and .createCounter() methods They are replaced by object creation via value types from previous commit --- ably.d.ts | 18 ---- src/plugins/objects/livecounter.ts | 73 +--------------- src/plugins/objects/livecountervaluetype.ts | 56 +++++++++++- src/plugins/objects/livemap.ts | 95 --------------------- src/plugins/objects/livemapvaluetype.ts | 3 - src/plugins/objects/realtimeobject.ts | 63 -------------- 6 files changed, 53 insertions(+), 255 deletions(-) diff --git a/ably.d.ts b/ably.d.ts index ac5fb41034..0b996b6b91 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2321,24 +2321,6 @@ export declare interface RealtimeObject { */ getPathObject>(): Promise>>; - /** - * Creates a new {@link LiveMapDeprecated} object instance with the provided entries. - * - * @param entries - The initial entries for the new {@link LiveMapDeprecated} object. - * @returns A promise which, upon success, will be fulfilled with a {@link LiveMapDeprecated} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. - * @experimental - */ - createMap(entries?: T): Promise>; - - /** - * Creates a new {@link LiveCounterDeprecated} object instance with the provided `count` value. - * - * @param count - The initial value for the new {@link LiveCounterDeprecated} object. - * @returns A promise which, upon success, will be fulfilled with a {@link LiveCounterDeprecated} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. - * @experimental - */ - createCounter(count?: number): Promise; - /** * Allows you to group multiple operations together and send them to the Ably service in a single channel message. * As a result, other clients will receive the changes as a single channel message after the batch function has completed. diff --git a/src/plugins/objects/livecounter.ts b/src/plugins/objects/livecounter.ts index 9dc0d821ca..4895aebdcd 100644 --- a/src/plugins/objects/livecounter.ts +++ b/src/plugins/objects/livecounter.ts @@ -1,13 +1,5 @@ import { LiveObject, LiveObjectData, LiveObjectUpdate, LiveObjectUpdateNoop } from './liveobject'; -import { ObjectId } from './objectid'; -import { - createInitialValueJSONString, - ObjectData, - ObjectMessage, - ObjectOperation, - ObjectOperationAction, - ObjectsCounterOp, -} from './objectmessage'; +import { ObjectData, ObjectMessage, ObjectOperation, ObjectOperationAction, ObjectsCounterOp } from './objectmessage'; import { RealtimeObject } from './realtimeobject'; export interface LiveCounterData extends LiveObjectData { @@ -42,18 +34,6 @@ export class LiveCounter extends LiveObject return obj; } - /** - * Returns a {@link LiveCounter} instance based on the provided COUNTER_CREATE object operation. - * The provided object operation must hold a valid counter object data. - * - * @internal - */ - static fromObjectOperation(realtimeObject: RealtimeObject, objectMessage: ObjectMessage): LiveCounter { - const obj = new LiveCounter(realtimeObject, objectMessage.operation!.objectId); - obj._mergeInitialDataFromCreateOperation(objectMessage.operation!, objectMessage); - return obj; - } - /** * @internal */ @@ -79,57 +59,6 @@ export class LiveCounter extends LiveObject return msg; } - /** - * @internal - */ - static async createCounterCreateMessage(realtimeObject: RealtimeObject, count?: number): Promise { - const client = realtimeObject.getClient(); - - if (count !== undefined && (typeof count !== 'number' || !Number.isFinite(count))) { - throw new client.ErrorInfo('Counter value should be a valid number', 40003, 400); - } - - const initialValueOperation = LiveCounter.createInitialValueOperation(count); - const initialValueJSONString = createInitialValueJSONString(initialValueOperation, client); - const nonce = client.Utils.cheapRandStr(); - const msTimestamp = await client.getTimestamp(true); - - const objectId = ObjectId.fromInitialValue( - client.Platform, - 'counter', - initialValueJSONString, - nonce, - msTimestamp, - ).toString(); - - const msg = ObjectMessage.fromValues( - { - operation: { - ...initialValueOperation, - action: ObjectOperationAction.COUNTER_CREATE, - objectId, - nonce, - initialValue: initialValueJSONString, - } as ObjectOperation, - }, - client.Utils, - client.MessageEncoding, - ); - - return msg; - } - - /** - * @internal - */ - static createInitialValueOperation(count?: number): Pick, 'counter'> { - return { - counter: { - count: count ?? 0, - }, - }; - } - /** @spec RTLC5 */ value(): number { this._realtimeObject.throwIfInvalidAccessApiConfiguration(); // RTLC5a, RTLC5b diff --git a/src/plugins/objects/livecountervaluetype.ts b/src/plugins/objects/livecountervaluetype.ts index ecf27b73a2..5e43833075 100644 --- a/src/plugins/objects/livecountervaluetype.ts +++ b/src/plugins/objects/livecountervaluetype.ts @@ -1,6 +1,12 @@ import type * as API from '../../../ably'; -import { LiveCounter } from './livecounter'; -import { ObjectMessage } from './objectmessage'; +import { ObjectId } from './objectid'; +import { + createInitialValueJSONString, + ObjectData, + ObjectMessage, + ObjectOperation, + ObjectOperationAction, +} from './objectmessage'; import { RealtimeObject } from './realtimeobject'; /** @@ -40,10 +46,52 @@ export class LiveCounterValueType implements API.LiveCounter { /** * @internal */ - static createCounterCreateMessage( + static async createCounterCreateMessage( realtimeObject: RealtimeObject, value: LiveCounterValueType, ): Promise { - return LiveCounter.createCounterCreateMessage(realtimeObject, value._count); + const client = realtimeObject.getClient(); + const count = value._count; + + if (count !== undefined && (typeof count !== 'number' || !Number.isFinite(count))) { + throw new client.ErrorInfo('Counter value should be a valid number', 40003, 400); + } + + const initialValueOperation = LiveCounterValueType.createInitialValueOperation(count); + const initialValueJSONString = createInitialValueJSONString(initialValueOperation, client); + const nonce = client.Utils.cheapRandStr(); + const msTimestamp = await client.getTimestamp(true); + + const objectId = ObjectId.fromInitialValue( + client.Platform, + 'counter', + initialValueJSONString, + nonce, + msTimestamp, + ).toString(); + + const msg = ObjectMessage.fromValues( + { + operation: { + ...initialValueOperation, + action: ObjectOperationAction.COUNTER_CREATE, + objectId, + nonce, + initialValue: initialValueJSONString, + } as ObjectOperation, + }, + client.Utils, + client.MessageEncoding, + ); + + return msg; + } + + private static createInitialValueOperation(count?: number): Pick, 'counter'> { + return { + counter: { + count: count ?? 0, + }, + }; } } diff --git a/src/plugins/objects/livemap.ts b/src/plugins/objects/livemap.ts index 7032cb9e83..731f35af8a 100644 --- a/src/plugins/objects/livemap.ts +++ b/src/plugins/objects/livemap.ts @@ -5,9 +5,7 @@ import type * as API from '../../../ably'; import { LiveCounterValueType } from './livecountervaluetype'; import { LiveMapValueType } from './livemapvaluetype'; import { LiveObject, LiveObjectData, LiveObjectUpdate, LiveObjectUpdateNoop } from './liveobject'; -import { ObjectId } from './objectid'; import { - createInitialValueJSONString, ObjectData, ObjectMessage, ObjectOperation, @@ -81,25 +79,6 @@ export class LiveMap extends LiveObject( - realtimeObject: RealtimeObject, - objectMessage: ObjectMessage, - ): LiveMap { - const obj = new LiveMap( - realtimeObject, - objectMessage.operation!.map?.semantics!, - objectMessage.operation!.objectId, - ); - obj._mergeInitialDataFromCreateOperation(objectMessage.operation!, objectMessage); - return obj; - } - /** * @internal */ @@ -249,80 +228,6 @@ export class LiveMap extends LiveObject { - const client = realtimeObject.getClient(); - - if (entries !== undefined && (entries === null || typeof entries !== 'object')) { - throw new client.ErrorInfo('Map entries should be a key-value object', 40003, 400); - } - - Object.entries(entries ?? {}).forEach(([key, value]) => LiveMap.validateKeyValue(realtimeObject, key, value)); - - const initialValueOperation = LiveMap.createInitialValueOperation(entries); - const initialValueJSONString = createInitialValueJSONString(initialValueOperation, client); - const nonce = client.Utils.cheapRandStr(); - const msTimestamp = await client.getTimestamp(true); - - const objectId = ObjectId.fromInitialValue( - client.Platform, - 'map', - initialValueJSONString, - nonce, - msTimestamp, - ).toString(); - - const msg = ObjectMessage.fromValues( - { - operation: { - ...initialValueOperation, - action: ObjectOperationAction.MAP_CREATE, - objectId, - nonce, - initialValue: initialValueJSONString, - } as ObjectOperation, - }, - client.Utils, - client.MessageEncoding, - ); - - return msg; - } - - /** - * @internal - */ - static createInitialValueOperation(entries?: API.LiveMapType): Pick, 'map'> { - const mapEntries: Record> = {}; - - Object.entries(entries ?? {}).forEach(([key, value]) => { - let objectData: LiveMapObjectData; - if (value instanceof LiveObject) { - const typedObjectData: ObjectIdObjectData = { objectId: value.getObjectId() }; - objectData = typedObjectData; - } else { - const typedObjectData: ValueObjectData = { value: value as PrimitiveObjectValue }; - objectData = typedObjectData; - } - - mapEntries[key] = { - data: objectData, - }; - }); - - return { - map: { - semantics: ObjectsMapSemantics.LWW, - entries: mapEntries, - }, - }; - } - /** * Returns the value associated with the specified key in the underlying Map object. * diff --git a/src/plugins/objects/livemapvaluetype.ts b/src/plugins/objects/livemapvaluetype.ts index ce65440573..e592ab848d 100644 --- a/src/plugins/objects/livemapvaluetype.ts +++ b/src/plugins/objects/livemapvaluetype.ts @@ -116,9 +116,6 @@ export class LiveMapValueType = Record, diff --git a/src/plugins/objects/realtimeobject.ts b/src/plugins/objects/realtimeobject.ts index c54b49f0e0..a2ee00f3f2 100644 --- a/src/plugins/objects/realtimeobject.ts +++ b/src/plugins/objects/realtimeobject.ts @@ -126,69 +126,6 @@ export class RealtimeObject { } } - /** - * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. - * - * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally using the provided data and returns it. - * - * @returns A promise which resolves upon receiving the ACK message for the published operation message. A promise is resolved with an object containing provided data. - */ - async createMap(entries?: T): Promise> { - this.throwIfInvalidWriteApiConfiguration(); - - const msg = await LiveMap.createMapCreateMessage(this, entries); - const objectId = msg.operation?.objectId!; - - await this.publish([msg]); - - // we may have already received the MAP_CREATE operation at this point, as it could arrive before the ACK for our publish message. - // this means the object might already exist in the local pool, having been added during the usual MAP_CREATE operation process. - // here we check if the object is present, and return it if found; otherwise, create a new object on the client side. - if (this._objectsPool.get(objectId)) { - return this._objectsPool.get(objectId) as LiveMap; - } - - // we haven't received the MAP_CREATE operation yet, so we can create a new map object using the locally constructed object operation. - // we don't know the serials for map entries, so we assign an "earliest possible" serial to each entry, so that any subsequent operation can be applied to them. - // we mark the MAP_CREATE operation as merged for the object, guaranteeing its idempotency and preventing it from being applied again when the operation arrives. - const map = LiveMap.fromObjectOperation(this, msg); - this._objectsPool.set(objectId, map); - - return map; - } - - /** - * Send a COUNTER_CREATE operation to the realtime system to create a new counter object in the pool. - * - * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed COUNTER_CREATE operation, or if it wasn't received yet, the method creates a new object locally using the provided data and returns it. - * - * @returns A promise which resolves upon receiving the ACK message for the published operation message. A promise is resolved with an object containing provided data. - */ - async createCounter(count?: number): Promise { - this.throwIfInvalidWriteApiConfiguration(); - - const msg = await LiveCounter.createCounterCreateMessage(this, count); - const objectId = msg.operation?.objectId!; - - await this.publish([msg]); - - // we may have already received the COUNTER_CREATE operation at this point, as it could arrive before the ACK for our publish message. - // this means the object might already exist in the local pool, having been added during the usual COUNTER_CREATE operation process. - // here we check if the object is present, and return it if found; otherwise, create a new object on the client side. - if (this._objectsPool.get(objectId)) { - return this._objectsPool.get(objectId) as LiveCounter; - } - - // we haven't received the COUNTER_CREATE operation yet, so we can create a new counter object using the locally constructed object operation. - // we mark the COUNTER_CREATE operation as merged for the object, guaranteeing its idempotency. this ensures we don't double count the initial counter value when the operation arrives. - const counter = LiveCounter.fromObjectOperation(this, msg); - this._objectsPool.set(objectId, counter); - - return counter; - } - on(event: ObjectsEvent, callback: ObjectsEventCallback): OnObjectsEventResponse { // this public API method can be called without specific configuration, so checking for invalid settings is unnecessary. this._eventEmitterPublic.on(event, callback); From 1d98cc32401e9c3591ec7d114134aad9ba84b9f2 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Tue, 4 Nov 2025 07:36:54 +0000 Subject: [PATCH 14/45] Streamline handling of OBJECT_DELETE operations --- src/plugins/objects/livecounter.ts | 18 +++++++++--------- src/plugins/objects/livemap.ts | 18 +++++++++--------- src/plugins/objects/liveobject.ts | 18 ++++++++++++------ 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/plugins/objects/livecounter.ts b/src/plugins/objects/livecounter.ts index 48fa5984d1..02d156cc6c 100644 --- a/src/plugins/objects/livecounter.ts +++ b/src/plugins/objects/livecounter.ts @@ -127,7 +127,7 @@ export class LiveCounter extends LiveObject return; } - let update: LiveCounterUpdate | LiveObjectUpdateNoop = { noop: true }; + let update: LiveCounterUpdate | LiveObjectUpdateNoop; switch (op.action) { case ObjectOperationAction.COUNTER_CREATE: update = this._applyCounterCreate(op, msg); @@ -144,7 +144,7 @@ export class LiveCounter extends LiveObject break; case ObjectOperationAction.OBJECT_DELETE: - this._applyObjectDelete(msg); + update = this._applyObjectDelete(msg); break; default: @@ -205,23 +205,23 @@ export class LiveCounter extends LiveObject } const previousDataRef = this._dataRef; + let update: LiveCounterUpdate; if (objectState.tombstone) { // tombstone this object and ignore the data from the object state message - this.tombstone(objectMessage); + update = this.tombstone(objectMessage); } else { - // override data for this object with data from the object state + // otherwise override data for this object with data from the object state this._createOperationIsMerged = false; // RTLC6b this._dataRef = { data: objectState.counter?.count ?? 0 }; // RTLC6c // RTLC6d if (!this._client.Utils.isNil(objectState.createOp)) { this._mergeInitialDataFromCreateOperation(objectState.createOp, objectMessage); } - } - // if object got tombstoned, the update object will include all data that got cleared. - // otherwise it is a diff between previous value and new value from object state. - const update = this._updateFromDataDiff(previousDataRef, this._dataRef); - update.objectMessage = objectMessage; + // update will contain the diff between previous value and new value from object state + update = this._updateFromDataDiff(previousDataRef, this._dataRef); + update.objectMessage = objectMessage; + } return update; } diff --git a/src/plugins/objects/livemap.ts b/src/plugins/objects/livemap.ts index c7a5fbb101..e727f4572c 100644 --- a/src/plugins/objects/livemap.ts +++ b/src/plugins/objects/livemap.ts @@ -379,7 +379,7 @@ export class LiveMap extends LiveObject | LiveObjectUpdateNoop = { noop: true }; + let update: LiveMapUpdate | LiveObjectUpdateNoop; switch (op.action) { case ObjectOperationAction.MAP_CREATE: update = this._applyMapCreate(op, msg); @@ -406,7 +406,7 @@ export class LiveMap extends LiveObject extends LiveObject; if (objectState.tombstone) { // tombstone this object and ignore the data from the object state message - this.tombstone(objectMessage); + update = this.tombstone(objectMessage); } else { - // override data for this object with data from the object state + // otherwise override data for this object with data from the object state this._createOperationIsMerged = false; // RTLM6b this._dataRef = this._liveMapDataFromMapEntries(objectState.map?.entries ?? {}); // RTLM6c // RTLM6d if (!this._client.Utils.isNil(objectState.createOp)) { this._mergeInitialDataFromCreateOperation(objectState.createOp, objectMessage); } - } - // if object got tombstoned, the update object will include all data that got cleared. - // otherwise it is a diff between previous value and new value from object state. - const update = this._updateFromDataDiff(previousDataRef, this._dataRef); - update.objectMessage = objectMessage; + // update will contain the diff between previous value and new value from object state + update = this._updateFromDataDiff(previousDataRef, this._dataRef); + update.objectMessage = objectMessage; + } // Update parent references based on the calculated diff this._updateParentReferencesFromUpdate(update, previousDataRef); diff --git a/src/plugins/objects/liveobject.ts b/src/plugins/objects/liveobject.ts index e6959a0440..832089bcec 100644 --- a/src/plugins/objects/liveobject.ts +++ b/src/plugins/objects/liveobject.ts @@ -22,6 +22,8 @@ export interface LiveObjectUpdate { update: any; /** Object message that caused an update to an object, if available */ objectMessage?: ObjectMessage; + /** Indicates whether this update is a result of a tombstone (delete) operation. */ + tombstone?: boolean; } export interface LiveObjectUpdateNoop { @@ -139,6 +141,11 @@ export abstract class LiveObject< this._notifyInstanceSubscriptions(update); this._notifyPathSubscriptions(update); + + if (update.tombstone) { + // deregister all listeners if update was a result of a tombstone operation + this._subscriptions.off(); + } } /** @@ -146,7 +153,7 @@ export abstract class LiveObject< * * @internal */ - tombstone(objectMessage: ObjectMessage): void { + tombstone(objectMessage: ObjectMessage): TUpdate { this._tombstone = true; if (objectMessage.serialTimestamp != null) { this._tombstonedAt = objectMessage.serialTimestamp; @@ -161,12 +168,11 @@ export abstract class LiveObject< } const update = this.clearData(); update.objectMessage = objectMessage; + update.tombstone = true; this._lifecycleEvents.emit(LiveObjectLifecycleEvent.deleted); - // notify subscribers about the delete operation and then deregister all listeners - this.notifyUpdated(update); - this._subscriptions.off(); + return update; } /** @@ -307,8 +313,8 @@ export abstract class LiveObject< return !siteSerial || opSerial > siteSerial; } - protected _applyObjectDelete(objectMessage: ObjectMessage): void { - this.tombstone(objectMessage); + protected _applyObjectDelete(objectMessage: ObjectMessage): TUpdate { + return this.tombstone(objectMessage); } private _notifyInstanceSubscriptions(update: TUpdate): void { From eb9c2d77102442e5953844b7a9c1f2a146f86915 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Fri, 3 Oct 2025 10:41:59 +0100 Subject: [PATCH 15/45] Implement .compact() method for PathObject and Instance types for LiveObjects Resolves PUB-2064 --- ably.d.ts | 117 ++++++++--- src/plugins/objects/instance.ts | 11 +- src/plugins/objects/livemap.ts | 29 +++ src/plugins/objects/pathobject.ts | 31 ++- src/plugins/objects/realtimeobject.ts | 2 +- test/realtime/objects.test.js | 279 ++++++++++++++++++++++++++ 6 files changed, 431 insertions(+), 38 deletions(-) diff --git a/ably.d.ts b/ably.d.ts index 448142fa00..f2bc1a7069 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2415,11 +2415,33 @@ export type LiveObject = LiveMap | LiveCounter; */ export type Value = LiveObject | Primitive; +/** + * CompactedValue transforms LiveObject types into plain JavaScript equivalents. + * LiveMap becomes an object, LiveCounter becomes a number, primitives remain unchanged. + */ +export type CompactedValue = + // LiveMap types + [T] extends [LiveMap] + ? { [K in keyof U]: CompactedValue } + : [T] extends [LiveMap | undefined] + ? { [K in keyof U]: CompactedValue } | undefined + : // LiveCounter types + [T] extends [LiveCounter] + ? number + : [T] extends [LiveCounter | undefined] + ? number | undefined + : // Primitive types + [T] extends [Primitive] + ? T + : [T] extends [Primitive | undefined] + ? T + : any; + /** * PathObjectBase defines the set of common methods on a PathObject - * that are present regardless of the underlying type specified by the type parameter T. + * that are present regardless of the underlying type. */ -interface PathObjectBase<_T extends Value> { +interface PathObjectBase { /** * Get the fully-qualified path string for this PathObject. * @@ -2430,14 +2452,6 @@ interface PathObjectBase<_T extends Value> { */ path(): string; - /** - * Get a JavaScript object representation of the object at this path. - * If the path does not resolve to any specific entry, returns `undefined`. - * - * @experimental - */ - compact(): any; - /** * Registers a listener that is called each time the object or a primitive value at this path is updated. * @@ -2528,7 +2542,7 @@ interface LiveMapPathObjectCollectionMethods = R * The type parameter T describes the expected structure of the map's entries. */ export interface LiveMapPathObject = Record> - extends PathObjectBase>, + extends PathObjectBase, PathObjectCollectionMethods, LiveMapPathObjectCollectionMethods, LiveMapOperations { @@ -2550,12 +2564,19 @@ export interface LiveMapPathObject = Record | undefined; + + /** + * Get a JavaScript object representation of the map at this path. + * + * @experimental + */ + compact(): CompactedValue>; } /** * A PathObject representing a {@link LiveCounter} instance at a specific path. */ -export interface LiveCounterPathObject extends PathObjectBase, LiveCounterOperations { +export interface LiveCounterPathObject extends PathObjectBase, LiveCounterOperations { /** * Get the current value of the counter instance currently at this path. * If the path does not resolve to any specific instance, returns `undefined`. @@ -2572,12 +2593,19 @@ export interface LiveCounterPathObject extends PathObjectBase, Live * @experimental */ instance(): LiveCounterInstance | undefined; + + /** + * Get a number representation of the counter at this path. + * + * @experimental + */ + compact(): CompactedValue; } /** * A PathObject representing a primitive value at a specific path. */ -export interface PrimitivePathObject extends PathObjectBase { +export interface PrimitivePathObject extends PathObjectBase { /** * Get the current value of the primitive currently at this path. * If the path does not resolve to any specific entry, returns `undefined`. @@ -2585,6 +2613,13 @@ export interface PrimitivePathObject extends Pa * @experimental */ value(): T | undefined; + + /** + * Get a JavaScript object representation of the primitive value at this path. + * + * @experimental + */ + compact(): CompactedValue; } /** @@ -2640,8 +2675,8 @@ interface AnyPathObjectCollectionMethods { * Each method supports type parameters to specify the expected * underlying type when needed. */ -export interface AnyPathObject - extends PathObjectBase, +export interface AnyPathObject + extends PathObjectBase, PathObjectCollectionMethods, AnyPathObjectCollectionMethods, AnyOperations { @@ -2671,6 +2706,13 @@ export interface AnyPathObject * @experimental */ instance(): Instance | undefined; + + /** + * Get a JavaScript object representation of the object at this path. + * + * @experimental + */ + compact(): CompactedValue | undefined; } /** @@ -2680,13 +2722,13 @@ export interface AnyPathObject * * @experimental */ -export type PathObject = [T] extends [LiveMap] - ? LiveMapPathObject +export type PathObject = [T] extends [LiveMap] + ? LiveMapPathObject : [T] extends [LiveCounter] ? LiveCounterPathObject : [T] extends [Primitive] ? PrimitivePathObject - : AnyPathObject; + : AnyPathObject; /** * Defines operations available on a {@link LiveMapPathObject}. @@ -2866,13 +2908,6 @@ interface InstanceBase { */ id(): string | undefined; - /** - * Get a JavaScript object representation of this instance. - * - * @experimental - */ - compact(): any; - /** * Registers a listener that is called each time this instance is updated. * @@ -2962,6 +2997,13 @@ export interface LiveMapInstance = Record(key: K): Instance | undefined; + + /** + * Get a JavaScript object representation of the map instance. + * + * @experimental + */ + compact(): CompactedValue>; } /** @@ -2975,6 +3017,13 @@ export interface LiveCounterInstance extends InstanceBase, LiveCoun * @experimental */ value(): number | undefined; + + /** + * Get a number representation of the counter instance. + * + * @experimental + */ + compact(): CompactedValue; } /** @@ -2991,6 +3040,13 @@ export interface PrimitiveInstance { * @experimental */ value(): T | undefined; + + /** + * Get a JavaScript object representation of the primitive value. + * + * @experimental + */ + compact(): CompactedValue; } /** @@ -3074,6 +3130,13 @@ export interface AnyInstance extends InstanceBase, AnyInstan * @experimental */ value(): T | undefined; + + /** + * Get a JavaScript object representation of the object instance. + * + * @experimental + */ + compact(): CompactedValue | undefined; } /** @@ -3083,8 +3146,8 @@ export interface AnyInstance extends InstanceBase, AnyInstan * * @experimental */ -export type Instance = [T] extends [LiveMap] - ? LiveMapInstance +export type Instance = [T] extends [LiveMap] + ? LiveMapInstance : [T] extends [LiveCounter] ? LiveCounterInstance : [T] extends [Primitive] diff --git a/src/plugins/objects/instance.ts b/src/plugins/objects/instance.ts index 66034333d4..c29976ba9d 100644 --- a/src/plugins/objects/instance.ts +++ b/src/plugins/objects/instance.ts @@ -1,6 +1,7 @@ import type BaseClient from 'common/lib/client/baseclient'; import type { AnyInstance, + CompactedValue, EventCallback, Instance, InstanceSubscriptionEvent, @@ -37,8 +38,12 @@ export class DefaultInstance implements AnyInstance { return this._value.getObjectId(); } - compact(): any { - throw new Error('Not implemented'); + compact(): CompactedValue | undefined { + if (this._value instanceof LiveMap) { + return this._value.compact() as CompactedValue; + } + + return this.value() as CompactedValue; } get(key: string): Instance | undefined { @@ -81,7 +86,7 @@ export class DefaultInstance implements AnyInstance { this._client.logger, this._client.Logger.LOG_MAJOR, 'DefaultInstance.value()', - `unexpected value type for instance, resolving to undefined; value=${this._value}`, + `unexpected value type for instance, resolving to undefined; value=${this._value}; type=${typeof this._value}`, ); // unknown type - return undefined return undefined; diff --git a/src/plugins/objects/livemap.ts b/src/plugins/objects/livemap.ts index e727f4572c..8beb70cf7d 100644 --- a/src/plugins/objects/livemap.ts +++ b/src/plugins/objects/livemap.ts @@ -1,6 +1,7 @@ import { dequal } from 'dequal'; import type * as API from '../../../ably'; +import { LiveCounter } from './livecounter'; import { LiveCounterValueType } from './livecountervaluetype'; import { LiveMapValueType } from './livemapvaluetype'; import { LiveObject, LiveObjectData, LiveObjectUpdate, LiveObjectUpdateNoop } from './liveobject'; @@ -543,6 +544,34 @@ export class LiveMap extends LiveObject = {} as Record; + + // 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(); + continue; + } + + if (value instanceof LiveCounter) { + result[key] = value.value(); + continue; + } + + // Other values return as is + result[key] = value; + } + + return result; + } + /** @spec RTLM4 */ protected _getZeroValueData(): LiveMapData { return { data: new Map() }; diff --git a/src/plugins/objects/pathobject.ts b/src/plugins/objects/pathobject.ts index 9343bd5089..77330eb41b 100644 --- a/src/plugins/objects/pathobject.ts +++ b/src/plugins/objects/pathobject.ts @@ -2,6 +2,7 @@ import type BaseClient from 'common/lib/client/baseclient'; import type * as API from '../../../ably'; import type { AnyPathObject, + CompactedValue, EventCallback, Instance, PathObject, @@ -21,7 +22,7 @@ import { RealtimeObject } from './realtimeobject'; * Implementation of AnyPathObject interface. * Provides a generic implementation that can handle any type of PathObject operations. */ -export class DefaultPathObject implements AnyPathObject { +export class DefaultPathObject implements AnyPathObject { private _client: BaseClient; private _path: string[]; @@ -29,7 +30,7 @@ export class DefaultPathObject implements AnyPathObject private _realtimeObject: RealtimeObject, private _root: LiveMap, path: string[], - parent?: DefaultPathObject, + parent?: DefaultPathObject, ) { this._client = this._realtimeObject.getClient(); // copy parent path array @@ -47,10 +48,26 @@ export class DefaultPathObject implements AnyPathObject } /** - * Returns a compact representation of the object at this path + * Returns a JavaScript object representation of the object at this path + * If the path does not resolve to any specific entry, returns `undefined`. */ - compact(): any | undefined { - throw new Error('Not implemented'); + compact(): CompactedValue | undefined { + try { + const resolved = this._resolvePath(this._path); + + if (resolved instanceof LiveMap) { + return resolved.compact() as CompactedValue; + } + + return this.value() as CompactedValue; + } catch (error) { + if (this._client.Utils.isErrorInfoOrPartialErrorInfo(error) && error.code === 92005) { + // ignore path resolution errors and return undefined + return undefined; + } + // rethrow everything else + throw error; + } } /** @@ -61,7 +78,7 @@ export class DefaultPathObject implements AnyPathObject if (typeof key !== 'string') { throw new this._client.ErrorInfo(`Path key must be a string: ${key}`, 40003, 400); } - return new DefaultPathObject(this._realtimeObject, this._root, [key], this) as unknown as PathObject; + return new DefaultPathObject(this._realtimeObject, this._root, [key], this) as unknown as PathObject; } /** @@ -107,7 +124,7 @@ export class DefaultPathObject implements AnyPathObject } pathAsArray.push(currentSegment); - return new DefaultPathObject(this._realtimeObject, this._root, pathAsArray, this) as unknown as PathObject; + return new DefaultPathObject(this._realtimeObject, this._root, pathAsArray, this) as unknown as PathObject; } /** diff --git a/src/plugins/objects/realtimeobject.ts b/src/plugins/objects/realtimeobject.ts index 32b4b87767..93b6478c4a 100644 --- a/src/plugins/objects/realtimeobject.ts +++ b/src/plugins/objects/realtimeobject.ts @@ -103,7 +103,7 @@ export class RealtimeObject { await this._eventEmitterInternal.once(ObjectsEvent.synced); // RTO1c } - const pathObject = new DefaultPathObject>(this, this._objectsPool.getRoot(), []); + const pathObject = new DefaultPathObject(this, this._objectsPool.getRoot(), []); return pathObject; } diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js index 400e83b211..2641df4956 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/objects.test.js @@ -4293,6 +4293,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const errorMsg = 'Could not resolve value at path'; // Next operations should not throw and silently handle non-existent path + expect(nonExistentPathObj.compact(), 'Check PathObject.compact() for non-existent path returns undefined') + .to.be.undefined; expect(nonExistentPathObj.value(), 'Check PathObject.value() for non-existent path returns undefined').to.be .undefined; expect(nonExistentPathObj.instance(), 'Check PathObject.instance() for non-existent path returns undefined') @@ -4339,6 +4341,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const errorMsg = `Cannot resolve path segment 'nested' on non-collection type at path`; // Next operations should not throw and silently handle incorrect path + expect(wrongTypePathObj.compact(), 'Check PathObject.compact() for non-collection path returns undefined') + .to.be.undefined; expect(wrongTypePathObj.value(), 'Check PathObject.value() for non-collection path returns undefined').to.be .undefined; expect(wrongTypePathObj.instance(), 'Check PathObject.instance() for non-collection path returns undefined') @@ -4988,6 +4992,129 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }).to.throw('Subscription depth must be greater than 0 or undefined for infinite depth'); }, }, + + { + description: 'PathObject.compact() returns correct representation for primitive values', + action: async (ctx) => { + const { entryPathObject, entryInstance, helper } = ctx; + + const keysUpdatedPromise = Promise.all( + primitiveKeyData.map((x) => waitForMapKeyUpdate(entryInstance, x.key)), + ); + await Promise.all( + primitiveKeyData.map(async (keyData) => { + let value; + if (keyData.data.bytes != null) { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + value = BufferUtils.base64Decode(keyData.data.bytes); + } else if (keyData.data.json != null) { + value = JSON.parse(keyData.data.json); + } else { + value = keyData.data.number ?? keyData.data.string ?? keyData.data.boolean; + } + + await entryPathObject.set(keyData.key, value); + }), + ); + await keysUpdatedPromise; + + primitiveKeyData.forEach((keyData) => { + const pathObj = entryPathObject.get(keyData.key); + const compactValue = pathObj.compact(); + const expectedValue = pathObj.value(); + + expect(compactValue).to.deep.equal( + expectedValue, + `Check PathObject.compact() returns correct value for primitive "${keyData.key}"`, + ); + }); + }, + }, + + { + description: 'PathObject.compact() returns number for LiveCounter objects', + action: async (ctx) => { + const { entryInstance, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); + await entryPathObject.set('counter', LiveCounter.create(42)); + await keyUpdatedPromise; + + const compactValue = entryPathObject.get('counter').compact(); + expect(compactValue).to.equal(42, 'Check PathObject.compact() returns number for LiveCounter'); + }, + }, + + { + description: 'PathObject.compact() returns plain object for LiveMap objects', + action: async (ctx) => { + const { entryInstance, entryPathObject } = ctx; + + // Create nested structure with different value types + const keysUpdatedPromise = Promise.all([waitForMapKeyUpdate(entryInstance, 'nestedMap')]); + await entryPathObject.set( + 'nestedMap', + LiveMap.create({ + stringKey: 'stringValue', + numberKey: 123, + booleanKey: true, + counterKey: LiveCounter.create(99), + array: [1, 2, 3], + obj: { nested: 'value' }, + }), + ); + await keysUpdatedPromise; + + const compactValue = entryPathObject.get('nestedMap').compact(); + const expected = { + stringKey: 'stringValue', + numberKey: 123, + booleanKey: true, + counterKey: 99, + array: [1, 2, 3], + obj: { nested: 'value' }, + }; + + expect(compactValue).to.deep.equal(expected, 'Check compact object has expected value'); + }, + }, + + { + description: 'PathObject.compact() handles complex nested structures', + action: async (ctx) => { + const { entryInstance, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'complex'); + await entryPathObject.set( + 'complex', + LiveMap.create({ + level1: LiveMap.create({ + level2: LiveMap.create({ + counter: LiveCounter.create(10), + primitive: 'deep value', + }), + directCounter: LiveCounter.create(20), + }), + topLevelCounter: LiveCounter.create(30), + }), + ); + await keyUpdatedPromise; + + const compactValue = entryPathObject.get('complex').compact(); + const expected = { + level1: { + level2: { + counter: 10, + primitive: 'deep value', + }, + directCounter: 20, + }, + topLevelCounter: 30, + }; + + expect(compactValue).to.deep.equal(expected, 'Check complex nested structure is compacted correctly'); + }, + }, ]; const instanceScenarios = [ @@ -5631,6 +5758,158 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expect(goodListenerCalled, 'Check good listener was called').to.be.true; }, }, + + { + description: 'DefaultInstance.compact() returns correct representation for primitive values', + action: async (ctx) => { + const { entryInstance, entryPathObject, helper } = ctx; + + const keysUpdatedPromise = Promise.all( + primitiveKeyData.map((x) => waitForMapKeyUpdate(entryInstance, x.key)), + ); + await Promise.all( + primitiveKeyData.map(async (keyData) => { + let value; + if (keyData.data.bytes != null) { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + value = BufferUtils.base64Decode(keyData.data.bytes); + } else if (keyData.data.json != null) { + value = JSON.parse(keyData.data.json); + } else { + value = keyData.data.number ?? keyData.data.string ?? keyData.data.boolean; + } + + await entryPathObject.set(keyData.key, value); + }), + ); + await keysUpdatedPromise; + + primitiveKeyData.forEach((keyData) => { + const childInstance = entryInstance.get(keyData.key); + const compactValue = childInstance.compact(); + const expectedValue = childInstance.value(); + + expect(compactValue).to.deep.equal( + expectedValue, + `Check DefaultInstance.compact() returns correct value for primitive "${keyData.key}"`, + ); + }); + }, + }, + + { + description: 'DefaultInstance.compact() returns number for LiveCounter objects', + action: async (ctx) => { + const { entryInstance, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); + await entryPathObject.set('counter', LiveCounter.create(42)); + await keyUpdatedPromise; + + const compactValue = entryInstance.get('counter').compact(); + expect(compactValue).to.equal(42, 'Check DefaultInstance.compact() returns number for LiveCounter'); + }, + }, + + { + description: 'DefaultInstance.compact() returns plain object for LiveMap objects', + action: async (ctx) => { + const { entryInstance, entryPathObject } = ctx; + + // Create nested structure with different value types + const keysUpdatedPromise = Promise.all([waitForMapKeyUpdate(entryInstance, 'nestedMap')]); + await entryPathObject.set( + 'nestedMap', + LiveMap.create({ + stringKey: 'stringValue', + numberKey: 456, + booleanKey: false, + counterKey: LiveCounter.create(111), + array: [1, 2, 3], + obj: { nested: 'value' }, + }), + ); + await keysUpdatedPromise; + + const compactValue = entryInstance.get('nestedMap').compact(); + const expected = { + stringKey: 'stringValue', + numberKey: 456, + booleanKey: false, + counterKey: 111, + array: [1, 2, 3], + obj: { nested: 'value' }, + }; + + expect(compactValue).to.deep.equal(expected, 'Check compact object has expected value'); + }, + }, + + { + description: 'DefaultInstance.compact() handles complex nested structures', + action: async (ctx) => { + const { entryInstance, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'complex'); + await entryPathObject.set( + 'complex', + LiveMap.create({ + level1: LiveMap.create({ + level2: LiveMap.create({ + counter: LiveCounter.create(100), + primitive: 'instance deep value', + }), + directCounter: LiveCounter.create(200), + }), + topLevelCounter: LiveCounter.create(300), + }), + ); + await keyUpdatedPromise; + + const compactValue = entryInstance.get('complex').compact(); + const expected = { + level1: { + level2: { + counter: 100, + primitive: 'instance deep value', + }, + directCounter: 200, + }, + topLevelCounter: 300, + }; + + expect(compactValue).to.deep.equal(expected, 'Check complex nested structure is compacted correctly'); + }, + }, + + { + description: 'DefaultInstance.compact() and PathObject.compact() return equivalent results', + action: async (ctx) => { + const { entryInstance, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'comparison'); + await entryPathObject.set( + 'comparison', + LiveMap.create({ + counter: LiveCounter.create(50), + nested: LiveMap.create({ + value: 'test', + innerCounter: LiveCounter.create(25), + }), + primitive: 'comparison test', + }), + ); + await keyUpdatedPromise; + + const pathCompact = entryPathObject.get('comparison').compact(); + const instanceCompact = entryInstance.get('comparison').compact(); + + expect(pathCompact).to.deep.equal( + instanceCompact, + 'Check PathObject.compact() and DefaultInstance.compact() return equivalent results', + ); + }, + }, ]; /** @nospec */ From 1c87d9cadc47f0bc332eb18938f904d282e840d1 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Tue, 4 Nov 2025 09:25:00 +0000 Subject: [PATCH 16/45] Change PathObject/Instance .compact() to return base64-encoded strings for binary values --- ably.d.ts | 55 +++++++++++++++++++++++-------- src/plugins/objects/instance.ts | 8 ++++- src/plugins/objects/livemap.ts | 7 ++++ src/plugins/objects/pathobject.ts | 9 ++++- test/realtime/objects.test.js | 36 ++++++++++++++++---- 5 files changed, 93 insertions(+), 22 deletions(-) diff --git a/ably.d.ts b/ably.d.ts index f2bc1a7069..613bbc0798 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2417,7 +2417,7 @@ export type Value = LiveObject | Primitive; /** * CompactedValue transforms LiveObject types into plain JavaScript equivalents. - * LiveMap becomes an object, LiveCounter becomes a number, primitives remain unchanged. + * LiveMap becomes an object, LiveCounter becomes a number, binary values become base64-encoded strings, other primitives remain unchanged. */ export type CompactedValue = // LiveMap types @@ -2430,12 +2430,21 @@ export type CompactedValue = ? number : [T] extends [LiveCounter | undefined] ? number | undefined - : // Primitive types - [T] extends [Primitive] - ? T - : [T] extends [Primitive | undefined] - ? T - : any; + : // Binary types (converted to base64 strings) + [T] extends [Buffer] + ? string + : [T] extends [Buffer | undefined] + ? string | undefined + : [T] extends [ArrayBuffer] + ? string + : [T] extends [ArrayBuffer | undefined] + ? string | undefined + : // Other primitive types + [T] extends [Primitive] + ? T + : [T] extends [Primitive | undefined] + ? T + : any; /** * PathObjectBase defines the set of common methods on a PathObject @@ -2567,10 +2576,13 @@ export interface LiveMapPathObject = Record>; + compact(): CompactedValue> | undefined; } /** @@ -2596,10 +2608,11 @@ export interface LiveCounterPathObject extends PathObjectBase, LiveCounterOperat /** * Get a number representation of the counter at this path. + * If the path does not resolve to any specific instance, returns `undefined`. * * @experimental */ - compact(): CompactedValue; + compact(): CompactedValue | undefined; } /** @@ -2616,10 +2629,13 @@ export interface PrimitivePathObject extends Pa /** * Get a JavaScript object representation of the primitive value at this path. + * Binary values are returned as base64-encoded strings. + * + * If the path does not resolve to any specific entry, returns `undefined`. * * @experimental */ - compact(): CompactedValue; + compact(): CompactedValue | undefined; } /** @@ -2709,6 +2725,9 @@ export interface AnyPathObject /** * Get a JavaScript object representation of the object at this path. + * Binary values are returned as base64-encoded strings. + * + * If the path does not resolve to any specific entry, returns `undefined`. * * @experimental */ @@ -3000,10 +3019,13 @@ export interface LiveMapInstance = Record>; + compact(): CompactedValue> | undefined; } /** @@ -3020,10 +3042,11 @@ export interface LiveCounterInstance extends InstanceBase, LiveCoun /** * Get a number representation of the counter instance. + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. * * @experimental */ - compact(): CompactedValue; + compact(): CompactedValue | undefined; } /** @@ -3043,10 +3066,13 @@ export interface PrimitiveInstance { /** * Get a JavaScript object representation of the primitive value. + * Binary values are returned as base64-encoded strings. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. * * @experimental */ - compact(): CompactedValue; + compact(): CompactedValue | undefined; } /** @@ -3133,6 +3159,9 @@ export interface AnyInstance extends InstanceBase, AnyInstan /** * Get a JavaScript object representation of the object instance. + * Binary values are returned as base64-encoded strings. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. * * @experimental */ diff --git a/src/plugins/objects/instance.ts b/src/plugins/objects/instance.ts index c29976ba9d..7385f508af 100644 --- a/src/plugins/objects/instance.ts +++ b/src/plugins/objects/instance.ts @@ -43,7 +43,13 @@ export class DefaultInstance implements AnyInstance { return this._value.compact() as CompactedValue; } - return this.value() as CompactedValue; + const value = this.value(); + + if (this._client.Platform.BufferUtils.isBuffer(value)) { + return this._client.Platform.BufferUtils.base64Encode(value) as CompactedValue; + } + + return value as CompactedValue; } get(key: string): Instance | undefined { diff --git a/src/plugins/objects/livemap.ts b/src/plugins/objects/livemap.ts index 8beb70cf7d..012aaf53f5 100644 --- a/src/plugins/objects/livemap.ts +++ b/src/plugins/objects/livemap.ts @@ -547,6 +547,7 @@ export class LiveMap extends LiveObject extends LiveObject(): CompactedValue | undefined { try { @@ -59,7 +60,13 @@ export class DefaultPathObject implements AnyPathObject { return resolved.compact() as CompactedValue; } - return this.value() as CompactedValue; + const value = this.value(); + + if (this._client.Platform.BufferUtils.isBuffer(value)) { + return this._client.Platform.BufferUtils.base64Encode(value) as CompactedValue; + } + + return value as CompactedValue; } catch (error) { if (this._client.Utils.isErrorInfoOrPartialErrorInfo(error) && error.code === 92005) { // ignore path resolution errors and return undefined diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js index 2641df4956..b3bd0d56de 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/objects.test.js @@ -5021,7 +5021,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function primitiveKeyData.forEach((keyData) => { const pathObj = entryPathObject.get(keyData.key); const compactValue = pathObj.compact(); - const expectedValue = pathObj.value(); + // expect buffer values to be base64-encoded strings + helper.recordPrivateApi('call.BufferUtils.isBuffer'); + helper.recordPrivateApi('call.BufferUtils.base64Encode'); + const expectedValue = BufferUtils.isBuffer(pathObj.value()) + ? BufferUtils.base64Encode(pathObj.value()) + : pathObj.value(); expect(compactValue).to.deep.equal( expectedValue, @@ -5048,10 +5053,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.compact() returns plain object for LiveMap objects', action: async (ctx) => { - const { entryInstance, entryPathObject } = ctx; + const { entryInstance, entryPathObject, helper } = ctx; // Create nested structure with different value types const keysUpdatedPromise = Promise.all([waitForMapKeyUpdate(entryInstance, 'nestedMap')]); + helper.recordPrivateApi('call.BufferUtils.utf8Encode'); await entryPathObject.set( 'nestedMap', LiveMap.create({ @@ -5061,11 +5067,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function counterKey: LiveCounter.create(99), array: [1, 2, 3], obj: { nested: 'value' }, + buffer: BufferUtils.utf8Encode('value'), }), ); await keysUpdatedPromise; const compactValue = entryPathObject.get('nestedMap').compact(); + helper.recordPrivateApi('call.BufferUtils.utf8Encode'); + helper.recordPrivateApi('call.BufferUtils.base64Encode'); const expected = { stringKey: 'stringValue', numberKey: 123, @@ -5073,6 +5082,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function counterKey: 99, array: [1, 2, 3], obj: { nested: 'value' }, + buffer: BufferUtils.base64Encode(BufferUtils.utf8Encode('value')), }; expect(compactValue).to.deep.equal(expected, 'Check compact object has expected value'); @@ -5785,9 +5795,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await keysUpdatedPromise; primitiveKeyData.forEach((keyData) => { - const childInstance = entryInstance.get(keyData.key); - const compactValue = childInstance.compact(); - const expectedValue = childInstance.value(); + const instance = entryInstance.get(keyData.key); + const compactValue = instance.compact(); + // expect buffer values to be base64-encoded strings + helper.recordPrivateApi('call.BufferUtils.isBuffer'); + helper.recordPrivateApi('call.BufferUtils.base64Encode'); + const expectedValue = BufferUtils.isBuffer(instance.value()) + ? BufferUtils.base64Encode(instance.value()) + : instance.value(); expect(compactValue).to.deep.equal( expectedValue, @@ -5814,10 +5829,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.compact() returns plain object for LiveMap objects', action: async (ctx) => { - const { entryInstance, entryPathObject } = ctx; + const { entryInstance, entryPathObject, helper } = ctx; // Create nested structure with different value types const keysUpdatedPromise = Promise.all([waitForMapKeyUpdate(entryInstance, 'nestedMap')]); + helper.recordPrivateApi('call.BufferUtils.utf8Encode'); await entryPathObject.set( 'nestedMap', LiveMap.create({ @@ -5827,11 +5843,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function counterKey: LiveCounter.create(111), array: [1, 2, 3], obj: { nested: 'value' }, + buffer: BufferUtils.utf8Encode('value'), }), ); await keysUpdatedPromise; const compactValue = entryInstance.get('nestedMap').compact(); + helper.recordPrivateApi('call.BufferUtils.utf8Encode'); + helper.recordPrivateApi('call.BufferUtils.base64Encode'); const expected = { stringKey: 'stringValue', numberKey: 456, @@ -5839,6 +5858,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function counterKey: 111, array: [1, 2, 3], obj: { nested: 'value' }, + buffer: BufferUtils.base64Encode(BufferUtils.utf8Encode('value')), }; expect(compactValue).to.deep.equal(expected, 'Check compact object has expected value'); @@ -5885,9 +5905,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.compact() and PathObject.compact() return equivalent results', action: async (ctx) => { - const { entryInstance, entryPathObject } = ctx; + const { entryInstance, entryPathObject, helper } = ctx; const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'comparison'); + helper.recordPrivateApi('call.BufferUtils.utf8Encode'); await entryPathObject.set( 'comparison', LiveMap.create({ @@ -5897,6 +5918,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function innerCounter: LiveCounter.create(25), }), primitive: 'comparison test', + buffer: BufferUtils.utf8Encode('value'), }), ); await keyUpdatedPromise; From 63eb761792110947c52c008089eaaaa5519ecfdf Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Thu, 9 Oct 2025 06:36:52 +0100 Subject: [PATCH 17/45] Implement async iterator subscriptions API for LiveObjects Resolves PUB-2062 --- ably.d.ts | 25 ++ src/common/lib/util/utils.ts | 53 +++ src/plugins/objects/instance.ts | 10 + src/plugins/objects/pathobject.ts | 7 + test/common/modules/private_api_recorder.js | 7 +- test/realtime/objects.test.js | 346 ++++++++++++++++++++ 6 files changed, 447 insertions(+), 1 deletion(-) diff --git a/ably.d.ts b/ably.d.ts index 613bbc0798..acb3f63028 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2484,6 +2484,19 @@ interface PathObjectBase { listener: EventCallback, options?: PathObjectSubscriptionOptions, ): SubscribeResponse; + + /** + * Registers a subscription listener and returns an async iterator that yields + * subscription events each time the object or a primitive value at this path is updated. + * + * This method functions in the same way as the regular {@link PathObjectBase.subscribe | PathObject.subscribe()} method, + * but instead returns an async iterator that can be used in a `for await...of` loop for convenience. + * + * @param options - Optional subscription configuration. + * @returns An async iterator that yields {@link PathObjectSubscriptionEvent} objects. + * @experimental + */ + subscribeIterator(options?: PathObjectSubscriptionOptions): AsyncIterableIterator; } /** @@ -2948,6 +2961,18 @@ interface InstanceBase { * @experimental */ subscribe(listener: EventCallback>): SubscribeResponse; + + /** + * Registers a subscription listener and returns an async iterator that yields + * subscription events each time this instance is updated. + * + * This method functions in the same way as the regular {@link InstanceBase.subscribe | Instance.subscribe()} method, + * but instead returns an async iterator that can be used in a `for await...of` loop for convenience. + * + * @returns An async iterator that yields {@link InstanceSubscriptionEvent} objects. + * @experimental + */ + subscribeIterator(): AsyncIterableIterator>; } /** diff --git a/src/common/lib/util/utils.ts b/src/common/lib/util/utils.ts index df02eac98f..c37d2ca346 100644 --- a/src/common/lib/util/utils.ts +++ b/src/common/lib/util/utils.ts @@ -477,3 +477,56 @@ export async function withTimeoutAsync(promise: Promise, timeout = 5000, e type NonFunctionKeyNames = { [P in keyof A]: A[P] extends Function ? never : P }[keyof A]; export type Properties = Pick>; + +/** + * A subscription function that registers the provided listener and returns a function to deregister it. + */ +export type RegisterListenerFunction = (listener: (event: T) => void) => () => void; + +/** + * Converts a listener-based event emitter API into an async iterator + * that can be consumed using a `for await...of` loop. + * + * @param registerListener - A function that registers a listener and returns a function to remove it + * @returns An async iterator that yields events from the listener + */ +export async function* listenerToAsyncIterator( + registerListener: RegisterListenerFunction, +): AsyncIterableIterator { + const eventQueue: T[] = []; + let resolveNext: ((event: T) => void) | null = null; + + const removeListener = registerListener((event: T) => { + if (resolveNext) { + // If we have a waiting promise, resolve it immediately + const resolve = resolveNext; + resolveNext = null; + resolve(event); + } else { + // Otherwise, queue the event for later consumption + eventQueue.push(event); + } + }); + + try { + while (true) { + if (eventQueue.length > 0) { + // If we have queued events, yield the next one + yield eventQueue.shift()!; + } else { + if (resolveNext) { + throw new ErrorInfo('Concurrent next() calls are not supported', 40000, 400); + } + + // Otherwise wait for the next event to arrive + const event = await new Promise((resolve) => { + resolveNext = resolve; + }); + yield event; + } + } + } finally { + // Clean up when iterator is done or abandoned + removeListener(); + } +} diff --git a/src/plugins/objects/instance.ts b/src/plugins/objects/instance.ts index 7385f508af..da9e35f1d2 100644 --- a/src/plugins/objects/instance.ts +++ b/src/plugins/objects/instance.ts @@ -173,4 +173,14 @@ export class DefaultInstance implements AnyInstance { }); }); } + + subscribeIterator(): AsyncIterableIterator> { + if (!(this._value instanceof LiveObject)) { + throw new this._client.ErrorInfo('Cannot subscribe to a non-LiveObject instance', 92007, 400); + } + return this._client.Utils.listenerToAsyncIterator((listener) => { + const { unsubscribe } = this.subscribe(listener); + return unsubscribe; + }); + } } diff --git a/src/plugins/objects/pathobject.ts b/src/plugins/objects/pathobject.ts index 6ca8d5937a..0114098324 100644 --- a/src/plugins/objects/pathobject.ts +++ b/src/plugins/objects/pathobject.ts @@ -347,6 +347,13 @@ export class DefaultPathObject implements AnyPathObject { return this._realtimeObject.getPathObjectSubscriptionRegister().subscribe(this._path, listener, options ?? {}); } + subscribeIterator(options?: PathObjectSubscriptionOptions): AsyncIterableIterator { + return this._client.Utils.listenerToAsyncIterator((listener) => { + const { unsubscribe } = this.subscribe(listener, options); + return unsubscribe; + }); + } + private _resolvePath(path: string[]): Value { // TODO: remove type assertion when internal LiveMap is updated to support new path based type system let current: Value = this._root as unknown as API.LiveMap; diff --git a/test/common/modules/private_api_recorder.js b/test/common/modules/private_api_recorder.js index 4498a45414..08af19c596 100644 --- a/test/common/modules/private_api_recorder.js +++ b/test/common/modules/private_api_recorder.js @@ -14,6 +14,7 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths) 'call.Defaults.getPort', 'call.Defaults.normaliseOptions', 'call.EventEmitter.emit', + 'call.EventEmitter.listeners', 'call.LiveObject.getObjectId', 'call.LiveObject.isTombstoned', 'call.LiveObject.tombstonedAt', @@ -28,6 +29,7 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths) 'call.ProtocolMessage.setFlag', 'call.RealtimeObject._objectsPool._onGCInterval', 'call.RealtimeObject._objectsPool.get', + 'call.RealtimeObject.getPathObjectSubscriptionRegister', 'call.Utils.copy', 'call.Utils.dataSizeBytes', 'call.Utils.getRetryTime', @@ -77,9 +79,12 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths) 'pass.clientOption.webSocketConnectTimeout', 'pass.clientOption.webSocketSlowTimeout', 'pass.clientOption.wsConnectivityCheckUrl', // actually ably-js public API (i.e. it’s in the TypeScript typings) but no other SDK has it. At the same time it's not entirely clear if websocket connectivity check should be considered an ably-js-specific functionality (as for other params above), so for the time being we consider it as private API + 'read.DefaultInstance._value', 'read.Defaults.version', - 'read.LiveMap._dataRef.data', 'read.EventEmitter.events', + 'read.LiveMap._dataRef.data', + 'read.LiveObject._subscriptions', + 'read.PathObjectSubscriptionRegister._subscriptions', 'read.Platform.Config.push', 'read.ProtocolMessage.channelSerial', 'read.Realtime._transports', diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js index b3bd0d56de..03a794b33d 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/objects.test.js @@ -4993,6 +4993,175 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, }, + { + description: 'PathObject.subscribeIterator() yields events for changes to the subscribed path', + action: async (ctx) => { + const { entryInstance, entryPathObject } = ctx; + + const iteratorPromise = (async () => { + const events = []; + for await (const event of entryPathObject.subscribeIterator()) { + expect(event.object, 'Check event object exists').to.exist; + expect(event.message, 'Check event message exists').to.exist; + events.push(event); + if (events.length >= 2) break; + } + return events; + })(); + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'testKey1'), + waitForMapKeyUpdate(entryInstance, 'testKey2'), + ]); + await entryPathObject.set('testKey1', 'testValue1'); + await entryPathObject.set('testKey2', 'testValue2'); + await keysUpdatedPromise; + + const events = await iteratorPromise; + + expect(events).to.have.lengthOf(2, 'Check received expected number of events'); + }, + }, + + { + description: 'PathObject.subscribeIterator() with depth option works correctly', + action: async (ctx) => { + const { entryInstance, entryPathObject } = ctx; + + const mapCreatedPromise = waitForMapKeyUpdate(entryInstance, 'map'); + await entryPathObject.set('map', LiveMap.create({})); + await mapCreatedPromise; + + const iteratorPromise = (async () => { + const events = []; + for await (const event of entryPathObject.get('map').subscribeIterator({ depth: 1 })) { + expect(event.object, 'Check event object exists').to.exist; + expect(event.message, 'Check event message exists').to.exist; + expect(event.message.operation).to.deep.include( + { + action: 'map.set', + objectId: entryPathObject.get('map').instance().id(), + }, + 'Check event message operation', + ); + // check mapOp separately so it doesn't break due to the additional data field with objectId in there + expect(event.message.operation.mapOp).to.deep.include( + { key: 'directKey' }, + 'Check event message operation mapOp', + ); + + events.push(event); + if (events.length >= 2) break; + } + return events; + })(); + + const map = entryInstance.get('map'); + // direct change - should register + let keyUpdatedPromise = waitForMapKeyUpdate(map, 'directKey'); + await map.set('directKey', LiveMap.create({})); + await keyUpdatedPromise; + + // nested change - should not register + keyUpdatedPromise = waitForMapKeyUpdate(map.get('directKey'), 'nestedKey'); + await map.get('directKey').set('nestedKey', 'nestedValue'); + await keyUpdatedPromise; + + // another direct change - should register + keyUpdatedPromise = waitForMapKeyUpdate(map, 'directKey'); + await map.set('directKey', LiveMap.create({})); + await keyUpdatedPromise; + + const events = await iteratorPromise; + + expect(events).to.have.lengthOf(2, 'Check received expected number of events'); + }, + }, + + { + description: 'PathObject.subscribeIterator() can be broken out of and subscription is removed properly', + action: async (ctx) => { + const { entryInstance, realtimeObject, entryPathObject, helper } = ctx; + + let eventCount = 0; + + const iteratorPromise = (async () => { + for await (const _ of entryPathObject.subscribeIterator()) { + eventCount++; + if (eventCount >= 2) break; + } + })(); + + helper.recordPrivateApi('call.RealtimeObject.getPathObjectSubscriptionRegister'); + helper.recordPrivateApi('read.PathObjectSubscriptionRegister._subscriptions'); + expect(realtimeObject.getPathObjectSubscriptionRegister()._subscriptions.size).to.equal( + 1, + 'Check one active subscription', + ); + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'testKey1'), + waitForMapKeyUpdate(entryInstance, 'testKey2'), + waitForMapKeyUpdate(entryInstance, 'testKey3'), + ]); + await entryPathObject.set('testKey1', 'testValue1'); + await entryPathObject.set('testKey2', 'testValue2'); + await entryPathObject.set('testKey3', 'testValue3'); // This shouldn't be processed + await keysUpdatedPromise; + + await iteratorPromise; + + helper.recordPrivateApi('call.RealtimeObject.getPathObjectSubscriptionRegister'); + helper.recordPrivateApi('read.PathObjectSubscriptionRegister._subscriptions'); + expect(realtimeObject.getPathObjectSubscriptionRegister()._subscriptions.size).to.equal( + 0, + 'Check no active subscriptions after breaking out of iterator', + ); + expect(eventCount).to.equal(2, 'Check only expected number of events received'); + }, + }, + + { + description: 'PathObject.subscribeIterator() handles multiple concurrent iterators independently', + action: async (ctx) => { + const { entryInstance, entryPathObject } = ctx; + + let iterator1Events = 0; + let iterator2Events = 0; + + const iterator1Promise = (async () => { + for await (const event of entryPathObject.subscribeIterator()) { + expect(event.object, 'Check event object exists').to.exist; + expect(event.message, 'Check event message exists').to.exist; + iterator1Events++; + if (iterator1Events >= 2) break; + } + })(); + + const iterator2Promise = (async () => { + for await (const event of entryPathObject.subscribeIterator()) { + expect(event.object, 'Check event object exists').to.exist; + expect(event.message, 'Check event message exists').to.exist; + iterator2Events++; + if (iterator2Events >= 1) break; // This iterator breaks after 1 event + } + })(); + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'testKey1'), + waitForMapKeyUpdate(entryInstance, 'testKey2'), + ]); + await entryPathObject.set('testKey1', 'testValue1'); + await entryPathObject.set('testKey2', 'testValue2'); + await keysUpdatedPromise; + + await Promise.all([iterator1Promise, iterator2Promise]); + + expect(iterator1Events).to.equal(2, 'Check iterator1 received expected events'); + expect(iterator2Events).to.equal(1, 'Check iterator2 received expected events'); + }, + }, + { description: 'PathObject.compact() returns correct representation for primitive values', action: async (ctx) => { @@ -5533,6 +5702,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }) .to.throw('Cannot subscribe to a non-LiveObject instance') .with.property('code', 92007); + expect(() => { + primitiveInstance.subscribeIterator(); + }) + .to.throw('Cannot subscribe to a non-LiveObject instance') + .with.property('code', 92007); }, }, @@ -5769,6 +5943,178 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, }, + { + description: 'DefaultInstance.subscribeIterator() yields events for LiveMap set/remove operations', + action: async (ctx) => { + const { entryInstance, entryPathObject } = ctx; + + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'map'); + await entryPathObject.set('map', LiveMap.create({})); + await keyUpdatedPromise; + + const map = entryInstance.get('map'); + + const iteratorPromise = (async () => { + const events = []; + for await (const event of map.subscribeIterator()) { + expect(event.object, 'Check event object exists').to.exist; + expect(event.object).to.equal(map, 'Check event object is the same map instance'); + expect(event.message, 'Check event message exists').to.exist; + expect(event.message.operation).to.deep.include( + events.length === 0 + ? { action: 'map.set', objectId: map.id(), mapOp: { key: 'foo', data: { value: 'bar' } } } + : { action: 'map.remove', objectId: map.id(), mapOp: { key: 'foo' } }, + 'Check event message operation', + ); + events.push(event); + if (events.length >= 2) break; + } + return events; + })(); + + keyUpdatedPromise = waitForMapKeyUpdate(map, 'foo'); + await map.set('foo', 'bar'); + await keyUpdatedPromise; + + keyUpdatedPromise = waitForMapKeyUpdate(map, 'foo'); + await map.remove('foo'); + await keyUpdatedPromise; + + const events = await iteratorPromise; + expect(events).to.have.lengthOf(2, 'Check received expected number of events'); + }, + }, + + { + description: 'DefaultInstance.subscribeIterator() yields events for LiveCounter increment/decrement', + action: async (ctx) => { + const { entryInstance, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); + await entryPathObject.set('counter', LiveCounter.create()); + await keyUpdatedPromise; + + const counter = entryInstance.get('counter'); + + const iteratorPromise = (async () => { + const events = []; + for await (const event of counter.subscribeIterator()) { + expect(event.object, 'Check event object exists').to.exist; + expect(event.object).to.equal(counter, 'Check event object is the same counter instance'); + expect(event.message, 'Check event message exists').to.exist; + expect(event.message.operation).to.deep.include( + { + action: 'counter.inc', + objectId: counter.id(), + counterOp: { amount: events.length === 0 ? 1 : -2 }, + }, + 'Check event message operation', + ); + events.push(event); + if (events.length >= 2) break; + } + return events; + })(); + + let counterUpdatedPromise = waitForCounterUpdate(counter); + await counter.increment(1); + await counterUpdatedPromise; + + counterUpdatedPromise = waitForCounterUpdate(counter); + await counter.decrement(2); + await counterUpdatedPromise; + + const events = await iteratorPromise; + expect(events).to.have.lengthOf(2, 'Check received expected number of events'); + }, + }, + + { + description: 'DefaultInstance.subscribeIterator() can be broken out of and subscription is removed properly', + action: async (ctx) => { + const { entryInstance, entryPathObject, helper } = ctx; + + const registeredListeners = (instance) => { + helper.recordPrivateApi('read.DefaultInstance._value'); + helper.recordPrivateApi('read.LiveObject._subscriptions'); + helper.recordPrivateApi('call.EventEmitter.listeners'); + return instance._value._subscriptions.listeners('updated'); + }; + + const instance = entryPathObject.instance(); + let eventCount = 0; + + const iteratorPromise = (async () => { + for await (const _ of instance.subscribeIterator()) { + eventCount++; + if (eventCount >= 2) break; + } + })(); + + expect(registeredListeners(instance).length).to.equal(1, 'Check one active listener'); + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'testKey1'), + waitForMapKeyUpdate(entryInstance, 'testKey2'), + waitForMapKeyUpdate(entryInstance, 'testKey3'), + ]); + await entryPathObject.set('testKey1', 'testValue1'); + await entryPathObject.set('testKey2', 'testValue2'); + await entryPathObject.set('testKey3', 'testValue3'); // This shouldn't be received + await keysUpdatedPromise; + + await iteratorPromise; + + expect(registeredListeners(instance)?.length ?? 0).to.equal( + 0, + 'Check no active listeners after breaking out of iterator', + ); + expect(eventCount).to.equal(2, 'Check only expected number of events received'); + }, + }, + + { + description: 'DefaultInstance.subscribeIterator() handles multiple concurrent iterators independently', + action: async (ctx) => { + const { entryInstance, entryPathObject } = ctx; + + const instance = entryPathObject.instance(); + let iterator1Events = 0; + let iterator2Events = 0; + + const iterator1Promise = (async () => { + for await (const event of instance.subscribeIterator()) { + expect(event.object, 'Check event object exists').to.exist; + expect(event.message, 'Check event message exists').to.exist; + iterator1Events++; + if (iterator1Events >= 2) break; + } + })(); + + const iterator2Promise = (async () => { + for await (const event of instance.subscribeIterator()) { + expect(event.object, 'Check event object exists').to.exist; + expect(event.message, 'Check event message exists').to.exist; + iterator2Events++; + if (iterator2Events >= 1) break; // This iterator breaks after 1 event + } + })(); + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'testKey1'), + waitForMapKeyUpdate(entryInstance, 'testKey2'), + ]); + await entryPathObject.set('testKey1', 'testValue1'); + await entryPathObject.set('testKey2', 'testValue2'); + await keysUpdatedPromise; + + await Promise.all([iterator1Promise, iterator2Promise]); + + expect(iterator1Events).to.equal(2, 'Check iterator1 received expected events'); + expect(iterator2Events).to.equal(1, 'Check iterator2 received expected events'); + }, + }, + { description: 'DefaultInstance.compact() returns correct representation for primitive values', action: async (ctx) => { From dd942337406535c340209c2fdb0167111c1d9876 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Fri, 7 Nov 2025 08:22:01 +0000 Subject: [PATCH 18/45] Update LiveObjects batch API to use PathObject/Instance and handle object creation This also removes the now-obsolete RealtimeObject.batch() API. Resolves PUB-2065 --- ably.d.ts | 481 +++++++++++++----- scripts/moduleReport.ts | 3 +- src/plugins/objects/batchcontext.ts | 167 +++--- .../objects/batchcontextlivecounter.ts | 41 -- src/plugins/objects/batchcontextlivemap.ts | 62 --- src/plugins/objects/instance.ts | 37 +- src/plugins/objects/livemap.ts | 68 +-- src/plugins/objects/pathobject.ts | 62 ++- src/plugins/objects/realtimeobject.ts | 20 - src/plugins/objects/rootbatchcontext.ts | 73 +++ test/realtime/objects.test.js | 463 +++++++---------- 11 files changed, 811 insertions(+), 666 deletions(-) delete mode 100644 src/plugins/objects/batchcontextlivecounter.ts delete mode 100644 src/plugins/objects/batchcontextlivemap.ts create mode 100644 src/plugins/objects/rootbatchcontext.ts diff --git a/ably.d.ts b/ably.d.ts index acb3f63028..ef8b254667 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -1663,13 +1663,13 @@ export type ObjectsEventCallback = () => void; export type LiveObjectLifecycleEventCallback = () => void; /** - * A function passed to {@link RealtimeObject.batch} to group multiple Objects operations into a single channel message. + * A function passed to the {@link BatchOperations.batch | batch} method to group multiple Objects operations into a single channel message. * - * Must not be `async`. + * The function must be synchronous. * - * @param batchContext - A {@link BatchContext} object that allows grouping Objects operations for this batch. + * @param ctx - The {@link BatchContext} used to group operations together. */ -export type BatchCallback = (batchContext: BatchContext) => void; +export type BatchFunction = (ctx: BatchContext) => void; // Internal Interfaces @@ -2321,22 +2321,6 @@ export declare interface RealtimeObject { */ getPathObject>(): Promise>>; - /** - * Allows you to group multiple operations together and send them to the Ably service in a single channel message. - * As a result, other clients will receive the changes as a single channel message after the batch function has completed. - * - * This method accepts a synchronous callback, which is provided with a {@link BatchContext} object. - * Use the context object to access Objects on a channel and batch operations for them. - * - * The objects' data is not modified inside the callback function. Instead, the objects will be updated - * when the batched operations are applied by the Ably service and echoed back to the client. - * - * @param callback - A batch callback function used to group operations together. Cannot be an `async` function. - * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental - */ - batch(callback: BatchCallback): Promise; - /** * Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. * @@ -2698,7 +2682,7 @@ interface AnyPathObjectCollectionMethods { } /** - * Represents a PathObject when its underlying type is not known. + * Represents a {@link PathObject} when its underlying type is not known. * Provides a unified interface that includes all possible methods. * * Each method supports type parameters to specify the expected @@ -2763,16 +2747,303 @@ export type PathObject = [T] extends [LiveMap] : AnyPathObject; /** - * Defines operations available on a {@link LiveMapPathObject}. + * BatchContextBase defines the set of common methods on a BatchContext + * that are present regardless of the underlying type. + */ +interface BatchContextBase { + /** + * Get the object ID of the underlying instance. + * + * If the underlying instance at runtime is not a {@link LiveObject}, returns `undefined`. + * + * @experimental + */ + id(): string | undefined; +} + +/** + * Defines collection methods available on a {@link LiveMapBatchContext}. + */ +interface LiveMapBatchContextCollectionMethods = Record> { + /** + * Returns an iterable of key-value pairs for each entry in the map. + * Each value is represented as a {@link BatchContext} corresponding to its key. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + entries(): IterableIterator<[keyof T, BatchContext]>; + + /** + * Returns an iterable of keys in the map. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + keys(): IterableIterator; + + /** + * Returns an iterable of values in the map. + * Each value is represented as a {@link BatchContext}. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + values(): IterableIterator>; + + /** + * Returns the number of entries in the map. + * + * If the underlying instance at runtime is not a map, returns `undefined`. + * + * @experimental + */ + size(): number | undefined; +} + +/** + * LiveMapBatchContext is a batch context wrapper for a LiveMap object. + * The type parameter T describes the expected structure of the map's entries. + */ +export interface LiveMapBatchContext = Record> + extends BatchContextBase, + BatchContextOperations>, + LiveMapBatchContextCollectionMethods { + /** + * Returns the value associated with a given key as a {@link BatchContext}. + * + * Returns `undefined` if the key doesn't exist in the map, if the referenced {@link LiveObject} has been deleted, + * or if this map object itself has been deleted. + * + * @param key - The key to retrieve the value for. + * @returns A {@link BatchContext} representing a {@link LiveObject}, a primitive type (string, number, boolean, JSON-serializable object or array, or binary data) or `undefined` if the key doesn't exist in a map or the referenced {@link LiveObject} has been deleted. Always `undefined` if this map object is deleted. + * @experimental + */ + get(key: K): BatchContext | undefined; + + /** + * Get a JavaScript object representation of the map instance. + * Binary values are returned as base64-encoded strings. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue> | undefined; +} + +/** + * LiveCounterBatchContext is a batch context wrapper for a LiveCounter object. + */ +export interface LiveCounterBatchContext extends BatchContextBase, BatchContextOperations { + /** + * Get the current value of the counter instance. + * If the underlying instance at runtime is not a counter, returns `undefined`. + * + * @experimental + */ + value(): number | undefined; + + /** + * Get a number representation of the counter instance. + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue | undefined; +} + +/** + * PrimitiveBatchContext is a batch context wrapper for a primitive value (string, number, boolean, JSON-serializable object or array, or binary data). + */ +export interface PrimitiveBatchContext { + /** + * Get the underlying primitive value. + * If the underlying instance at runtime is not a primitive value, returns `undefined`. + * + * @experimental + */ + value(): T | undefined; + + /** + * Get a JavaScript object representation of the primitive value. + * Binary values are returned as base64-encoded strings. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue | undefined; +} + +/** + * AnyBatchContextCollectionMethods defines all possible methods available on an BatchContext object + * for the underlying collection types. + */ +interface AnyBatchContextCollectionMethods { + // LiveMap collection methods + + /** + * Returns an iterable of key-value pairs for each entry in the map. + * Each value is represented as an {@link BatchContext} corresponding to its key. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + entries>(): IterableIterator<[keyof T, BatchContext]>; + + /** + * Returns an iterable of keys in the map. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + keys>(): IterableIterator; + + /** + * Returns an iterable of values in the map. + * Each value is represented as a {@link BatchContext}. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + values>(): IterableIterator>; + + /** + * Returns the number of entries in the map. + * + * If the underlying instance at runtime is not a map, returns `undefined`. + * + * @experimental + */ + size(): number | undefined; +} + +/** + * Represents a {@link BatchContext} when its underlying type is not known. + * Provides a unified interface that includes all possible methods. + * + * Each method supports type parameters to specify the expected + * underlying type when needed. */ -export interface LiveMapOperations = Record> { +export interface AnyBatchContext + extends BatchContextBase, + AnyBatchContextCollectionMethods, + BatchContextOperations { + /** + * Navigate to a child entry within the collection by obtaining the {@link BatchContext} at that entry. + * The entry in a collection is identified with a string key. + * + * Returns `undefined` if: + * - The underlying instance at runtime is not a collection object. + * - The specified key does not exist in the collection. + * - The referenced {@link LiveObject} has been deleted. + * - This collection object itself has been deleted. + * + * @param key - The key to retrieve the value for. + * @returns A {@link BatchContext} representing either a {@link LiveObject} or a primitive value (string, number, boolean, JSON-serializable object or array, or binary data), or `undefined` if the underlying instance at runtime is not a collection object, the key does not exist, the referenced {@link LiveObject} has been deleted, or this collection object itself has been deleted. + * @experimental + */ + get(key: string): BatchContext | undefined; + + /** + * Get the current value of the underlying counter or primitive. + * + * If the underlying instance at runtime is neither a counter nor a primitive value, returns `undefined`. + * + * @returns The current value of the underlying primitive or counter, or `undefined` if the value cannot be retrieved. + * @experimental + */ + value(): T | undefined; + + /** + * Get a JavaScript object representation of the object instance. + * Binary values are returned as base64-encoded strings. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue | undefined; +} + +/** + * BatchContextOperations transforms LiveObject operation methods to be synchronous and removes the `batch` method. + */ +type BatchContextOperations = { + [K in keyof T as K extends 'batch' ? never : K]: T[K] extends ( + this: infer This, + ...args: infer A + ) => PromiseLike + ? (this: This, ...args: A) => R + : T[K] extends (this: infer This, ...args: infer A) => infer R + ? (this: This, ...args: A) => R + : T[K]; +}; + +/** + * BatchContext wraps a specific object instance or entry in a specific collection + * object instance and provides synchronous operation methods that can be aggregated + * and applied as a single batch operation. + * + * The type parameter specifies the underlying type of the instance, + * and is used to infer the correct set of methods available for that type. + * + * @experimental + */ +export type BatchContext = [T] extends [LiveMap] + ? LiveMapBatchContext + : [T] extends [LiveCounter] + ? LiveCounterBatchContext + : [T] extends [Primitive] + ? PrimitiveBatchContext + : AnyBatchContext; + +/** + * Defines batch operations available on {@link LiveObject | LiveObjects}. + */ +export interface BatchOperations { + /** + * Batch multiple operations together using a batch context, which + * wraps the underlying {@link PathObject} or {@link Instance} from which the batch was called. + * The batch context always contains a resolved instance, even when called from a {@link PathObject}. + * If an instance cannot be resolved from the referenced path, or if the instance is not a {@link LiveObject}, + * this method throws an error. + * + * Batching enables you to group multiple operations together and send them to the Ably service in a single channel message. + * As a result, other clients will receive the changes in a single channel message once the batch function has completed. + * + * The objects' data is not modified inside the batch function. Instead, the objects will be updated + * when the batched operations are applied by the Ably service and echoed back to the client. + * + * @param fn - A synchronous function that receives a {@link BatchContext} used to group operations together. + * @returns A promise which resolves upon success of the batch operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + batch(fn: BatchFunction): Promise; +} + +/** + * Defines operations available on {@link LiveMap} objects. + */ +export interface LiveMapOperations = Record> + extends BatchOperations> { /** * Sends an operation to the Ably system to set a key to a specified value on a given {@link LiveMapInstance}, * or on the map instance resolved from the path when using {@link LiveMapPathObject}. * - * If called via {@link LiveMapInstance} and the underlying instance at runtime is not a map, - * or if called via {@link LiveMapPathObject} and the map instance at the specified path cannot - * be resolved at the time of the call, this method throws an error. + * If called from within the {@link BatchOperations.batch | batch} method, the operation is instead + * added to the current batch and sent once the batch function completes. + * + * If called via {@link LiveMapInstance} or {@link LiveMapBatchContext} and the underlying instance + * at runtime is not a map, or if called via {@link LiveMapPathObject} and the map instance + * at the specified path cannot be resolved at the time of the call, this method throws an error. * * This does not modify the underlying data of the map. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. @@ -2789,9 +3060,12 @@ export interface LiveMapOperations = Record = Record { /** * Sends an operation to the Ably system to increment the value of a given {@link LiveCounterInstance}, * or of the counter instance resolved from the path when using {@link LiveCounterPathObject}. * - * If called via {@link LiveCounterInstance} and the underlying instance at runtime is not a counter, - * or if called via {@link LiveCounterPathObject} and the counter instance at the specified path cannot - * be resolved at the time of the call, this method throws an error. + * If called from within the {@link BatchOperations.batch | batch} method, the operation is instead + * added to the current batch and sent once the batch function completes. + * + * If called via {@link LiveCounterInstance} or {@link LiveCounterBatchContext} and the underlying instance + * at runtime is not a counter, or if called via {@link LiveCounterPathObject} and the counter instance + * at the specified path cannot be resolved at the time of the call, this method throws an error. * * This does not modify the underlying data of the counter. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. @@ -2840,15 +3117,37 @@ export interface LiveCounterOperations { * Defines all possible operations available on an {@link AnyPathObject}. */ export interface AnyOperations { + /** + * Batch multiple operations together using a batch context, which + * wraps the underlying {@link PathObject} or {@link Instance} from which the batch was called. + * The batch context always contains a resolved instance, even when called from a {@link PathObject}. + * If an instance cannot be resolved from the referenced path, or if the instance is not a {@link LiveObject}, + * this method throws an error. + * + * Batching enables you to group multiple operations together and send them to the Ably service in a single channel message. + * As a result, other clients will receive the changes in a single channel message once the batch function has completed. + * + * The objects' data is not modified inside the batch function. Instead, the objects will be updated + * when the batched operations are applied by the Ably service and echoed back to the client. + * + * @param fn - A synchronous function that receives a {@link BatchContext} used to group operations together. + * @returns A promise which resolves upon success of the batch operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + batch(fn: BatchFunction): Promise; + // LiveMap operations /** * Sends an operation to the Ably system to set a key to a specified value on the underlying map when using {@link AnyInstance}, * or on the map instance resolved from the path when using {@link AnyPathObject}. * - * If called via {@link AnyInstance} and the underlying instance at runtime is not a map, - * or if called via {@link AnyPathObject} and the map instance at the specified path cannot - * be resolved at the time of the call, this method throws an error. + * If called from within the {@link BatchOperations.batch | batch} method, the operation is instead + * added to the current batch and sent once the batch function completes. + * + * If called via {@link AnyInstance} or {@link AnyBatchContext} and the underlying instance + * at runtime is not a map, or if called via {@link AnyPathObject} and the map instance + * at the specified path cannot be resolved at the time of the call, this method throws an error. * * This does not modify the underlying data of the map. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. @@ -2865,9 +3164,12 @@ export interface AnyOperations { * Sends an operation to the Ably system to remove a key from the underlying map when using {@link AnyInstance}, * or from the map instance resolved from the path when using {@link AnyPathObject}. * - * If called via {@link AnyInstance} and the underlying instance at runtime is not a map, - * or if called via {@link AnyPathObject} and the map instance at the specified path cannot - * be resolved at the time of the call, this method throws an error. + * If called from within the {@link BatchOperations.batch | batch} method, the operation is instead + * added to the current batch and sent once the batch function completes. + * + * If called via {@link AnyInstance} or {@link AnyBatchContext} and the underlying instance + * at runtime is not a map, or if called via {@link AnyPathObject} and the map instance + * at the specified path cannot be resolved at the time of the call, this method throws an error. * * This does not modify the underlying data of the map. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. @@ -2885,9 +3187,12 @@ export interface AnyOperations { * Sends an operation to the Ably system to increment the value of the underlying counter when using {@link AnyInstance}, * or of the counter instance resolved from the path when using {@link AnyPathObject}. * - * If called via {@link AnyInstance} and the underlying instance at runtime is not a counter, - * or if called via {@link AnyPathObject} and the counter instance at the specified path cannot - * be resolved at the time of the call, this method throws an error. + * If called from within the {@link BatchOperations.batch | batch} method, the operation is instead + * added to the current batch and sent once the batch function completes. + * + * If called via {@link AnyInstance} or {@link AnyBatchContext} and the underlying instance + * at runtime is not a counter, or if called via {@link AnyPathObject} and the counter instance + * at the specified path cannot be resolved at the time of the call, this method throws an error. * * This does not modify the underlying data of the counter. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. @@ -3147,7 +3452,7 @@ interface AnyInstanceCollectionMethods { } /** - * Represents a AnyInstance when its underlying type is not known. + * Represents an {@link Instance} when its underlying type is not known. * Provides a unified interface that includes all possible methods. * * Each method supports type parameters to specify the expected @@ -3463,94 +3768,6 @@ export declare interface OnObjectsEventResponse { off(): void; } -/** - * Enables grouping multiple Objects operations together by providing `BatchContext*` wrapper objects. - */ -export declare interface BatchContext { - /** - * Mirrors the {@link RealtimeObject.get} method and returns a {@link BatchContextLiveMap} wrapper for the entrypoint {@link LiveMapDeprecated} object on a channel. - * - * @returns A {@link BatchContextLiveMap} object. - * @experimental - */ - get(): BatchContextLiveMap; -} - -/** - * A wrapper around the {@link LiveMapDeprecated} object that enables batching operations inside a {@link BatchCallback}. - */ -export declare interface BatchContextLiveMap { - /** - * Mirrors the {@link LiveMapDeprecated.get} method and returns the value associated with a key in the map. - * - * @param key - The key to retrieve the value for. - * @returns A {@link LiveObjectDeprecated}, a primitive type (string, number, boolean, JSON-serializable object or array, or binary data) or `undefined` if the key doesn't exist in a map or the associated {@link LiveObjectDeprecated} has been deleted. Always `undefined` if this map object is deleted. - * @experimental - */ - get(key: TKey): T[TKey] | undefined; - - /** - * Returns the number of key-value pairs in the map. - * - * @experimental - */ - size(): number; - - /** - * Similar to the {@link LiveMapDeprecated.set} method, but instead, it adds an operation to set a key in the map with the provided value to the current batch, to be sent in a single message to the Ably service. - * - * This does not modify the underlying data of this object. Instead, the change is applied when - * the published operation is echoed back to the client and applied to the object. - * - * @param key - The key to set the value for. - * @param value - The value to assign to the key. - * @experimental - */ - set(key: TKey, value: T[TKey]): void; - - /** - * Similar to the {@link LiveMapDeprecated.remove} method, but instead, it adds an operation to remove a key from the map to the current batch, to be sent in a single message to the Ably service. - * - * This does not modify the underlying data of this object. Instead, the change is applied when - * the published operation is echoed back to the client and applied to the object. - * - * @param key - The key to set the value for. - * @experimental - */ - remove(key: TKey): void; -} - -/** - * A wrapper around the {@link LiveCounterDeprecated} object that enables batching operations inside a {@link BatchCallback}. - */ -export declare interface BatchContextLiveCounter { - /** - * Returns the current value of the counter. - * - * @experimental - */ - value(): number; - - /** - * Similar to the {@link LiveCounterDeprecated.increment} method, but instead, it adds an operation to increment the counter value to the current batch, to be sent in a single message to the Ably service. - * - * This does not modify the underlying data of this object. Instead, the change is applied when - * the published operation is echoed back to the client and applied to the object. - * - * @param amount - The amount by which to increase the counter value. - * @experimental - */ - increment(amount: number): void; - - /** - * An alias for calling {@link BatchContextLiveCounter.increment | BatchContextLiveCounter.increment(-amount)} - * - * @param amount - The amount by which to decrease the counter value. - * @experimental - */ - decrement(amount: number): void; -} - /** * The `LiveMap` class represents a key-value map data structure, similar to a JavaScript Map, where all changes are synchronized across clients in realtime. * Conflicts in a LiveMap are automatically resolved with last-write-wins (LWW) semantics, diff --git a/scripts/moduleReport.ts b/scripts/moduleReport.ts index db927ee17d..af5444d6ae 100644 --- a/scripts/moduleReport.ts +++ b/scripts/moduleReport.ts @@ -328,8 +328,6 @@ async function checkObjectsPluginFiles() { // These are the files that are allowed to contribute >= `threshold` bytes to the Objects bundle. const allowedFiles = new Set([ 'src/plugins/objects/batchcontext.ts', - 'src/plugins/objects/batchcontextlivecounter.ts', - 'src/plugins/objects/batchcontextlivemap.ts', 'src/plugins/objects/index.ts', 'src/plugins/objects/instance.ts', 'src/plugins/objects/livecounter.ts', @@ -343,6 +341,7 @@ async function checkObjectsPluginFiles() { 'src/plugins/objects/pathobject.ts', 'src/plugins/objects/pathobjectsubscriptionregister.ts', 'src/plugins/objects/realtimeobject.ts', + 'src/plugins/objects/rootbatchcontext.ts', 'src/plugins/objects/syncobjectsdatapool.ts', ]); diff --git a/src/plugins/objects/batchcontext.ts b/src/plugins/objects/batchcontext.ts index 358e8ff78a..b9b8bafc15 100644 --- a/src/plugins/objects/batchcontext.ts +++ b/src/plugins/objects/batchcontext.ts @@ -1,107 +1,124 @@ import type BaseClient from 'common/lib/client/baseclient'; -import type * as API from '../../../ably'; -import { BatchContextLiveCounter } from './batchcontextlivecounter'; -import { BatchContextLiveMap } from './batchcontextlivemap'; -import { ROOT_OBJECT_ID } from './constants'; +import type { AnyBatchContext, BatchContext, CompactedValue, Instance, Primitive, Value } from '../../../ably'; +import { DefaultInstance } from './instance'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; -import { ObjectMessage } from './objectmessage'; import { RealtimeObject } from './realtimeobject'; +import { RootBatchContext } from './rootbatchcontext'; -export class BatchContext { - private _client: BaseClient; - /** Maps object ids to the corresponding batch context object wrappers */ - private _wrappedObjects: Map> = new Map(); - private _queuedMessages: ObjectMessage[] = []; - private _isClosed = false; +export class DefaultBatchContext implements AnyBatchContext { + protected _client: BaseClient; constructor( - private _realtimeObject: RealtimeObject, - private _root: LiveMap, + protected _realtimeObject: RealtimeObject, + protected _instance: Instance, + protected _rootContext: RootBatchContext, ) { - this._client = _realtimeObject.getClient(); - this._wrappedObjects.set(this._root.getObjectId(), new BatchContextLiveMap(this, this._realtimeObject, this._root)); + this._client = this._realtimeObject.getClient(); } - get(): BatchContextLiveMap { + get(key: string): BatchContext | undefined { this._realtimeObject.throwIfInvalidAccessApiConfiguration(); - this.throwIfClosed(); - return this.getWrappedObject(ROOT_OBJECT_ID) as BatchContextLiveMap; + this._throwIfClosed(); + const instance = this._instance.get(key); + if (!instance) { + return undefined; + } + return this._rootContext.wrapInstance(instance) as unknown as BatchContext; } - /** - * @internal - */ - getWrappedObject(objectId: string): BatchContextLiveCounter | BatchContextLiveMap | undefined { - if (this._wrappedObjects.has(objectId)) { - return this._wrappedObjects.get(objectId); - } + value(): T | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + this._throwIfClosed(); + return this._instance.value(); + } - const originObject = this._realtimeObject.getPool().get(objectId); - if (!originObject) { - return undefined; - } + compact(): CompactedValue | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + this._throwIfClosed(); + return this._instance.compact(); + } + + id(): string | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + this._throwIfClosed(); + return this._instance.id(); + } - let wrappedObject: BatchContextLiveCounter | BatchContextLiveMap; - if (originObject instanceof LiveMap) { - wrappedObject = new BatchContextLiveMap(this, this._realtimeObject, originObject); - } else if (originObject instanceof LiveCounter) { - wrappedObject = new BatchContextLiveCounter(this, this._realtimeObject, originObject); - } else { - throw new this._client.ErrorInfo( - `Unknown LiveObject instance type: objectId=${originObject.getObjectId()}`, - 50000, - 500, - ); + *entries>(): IterableIterator<[keyof T, BatchContext]> { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + this._throwIfClosed(); + for (const [key, value] of this._instance.entries()) { + const ctx = this._rootContext.wrapInstance(value) as unknown as BatchContext; + yield [key, ctx]; } + } - this._wrappedObjects.set(objectId, wrappedObject); - return wrappedObject; + *keys>(): IterableIterator { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + this._throwIfClosed(); + yield* this._instance.keys(); } - /** - * @internal - */ - throwIfClosed(): void { - if (this.isClosed()) { - throw new this._client.ErrorInfo('Batch is closed', 40000, 400); + *values>(): IterableIterator> { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + this._throwIfClosed(); + for (const [_, value] of this.entries()) { + yield value; } } - /** - * @internal - */ - isClosed(): boolean { - return this._isClosed; + size(): number | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + this._throwIfClosed(); + return this._instance.size(); } - /** - * @internal - */ - close(): void { - this._isClosed = true; + set(key: string, value: Value): void { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); + this._throwIfClosed(); + if (!(this._instance as DefaultInstance).isLiveMap()) { + throw new this._client.ErrorInfo('Cannot set a key on a non-LiveMap instance', 92007, 400); + } + this._rootContext.queueMessages(async () => + LiveMap.createMapSetMessage(this._realtimeObject, this._instance.id()!, key, value as Primitive), + ); } - /** - * @internal - */ - queueMessage(msg: ObjectMessage): void { - this._queuedMessages.push(msg); + remove(key: string): void { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); + this._throwIfClosed(); + if (!(this._instance as DefaultInstance).isLiveMap()) { + throw new this._client.ErrorInfo('Cannot remove a key from a non-LiveMap instance', 92007, 400); + } + this._rootContext.queueMessages(async () => [ + LiveMap.createMapRemoveMessage(this._realtimeObject, this._instance.id()!, key), + ]); } - /** - * @internal - */ - async flush(): Promise { - try { - this.close(); + increment(amount?: number): void { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); + this._throwIfClosed(); + if (!(this._instance as DefaultInstance).isLiveCounter()) { + throw new this._client.ErrorInfo('Cannot increment a non-LiveCounter instance', 92007, 400); + } + this._rootContext.queueMessages(async () => [ + LiveCounter.createCounterIncMessage(this._realtimeObject, this._instance.id()!, amount ?? 1), + ]); + } + + decrement(amount?: number): void { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); + this._throwIfClosed(); + if (!(this._instance as DefaultInstance).isLiveCounter()) { + throw new this._client.ErrorInfo('Cannot decrement a non-LiveCounter instance', 92007, 400); + } + this.increment(-(amount ?? 1)); + } - if (this._queuedMessages.length > 0) { - await this._realtimeObject.publish(this._queuedMessages); - } - } finally { - this._wrappedObjects.clear(); - this._queuedMessages = []; + private _throwIfClosed(): void { + if (this._rootContext.isClosed()) { + throw new this._client.ErrorInfo('Batch is closed', 40000, 400); } } } diff --git a/src/plugins/objects/batchcontextlivecounter.ts b/src/plugins/objects/batchcontextlivecounter.ts deleted file mode 100644 index fc1a9f9ebf..0000000000 --- a/src/plugins/objects/batchcontextlivecounter.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type BaseClient from 'common/lib/client/baseclient'; -import { BatchContext } from './batchcontext'; -import { LiveCounter } from './livecounter'; -import { RealtimeObject } from './realtimeobject'; - -export class BatchContextLiveCounter { - private _client: BaseClient; - - constructor( - private _batchContext: BatchContext, - private _realtimeObject: RealtimeObject, - private _counter: LiveCounter, - ) { - this._client = this._realtimeObject.getClient(); - } - - value(): number { - this._realtimeObject.throwIfInvalidAccessApiConfiguration(); - this._batchContext.throwIfClosed(); - return this._counter.value(); - } - - increment(amount: number): void { - this._realtimeObject.throwIfInvalidWriteApiConfiguration(); - this._batchContext.throwIfClosed(); - const msg = LiveCounter.createCounterIncMessage(this._realtimeObject, this._counter.getObjectId(), amount); - this._batchContext.queueMessage(msg); - } - - decrement(amount: number): void { - this._realtimeObject.throwIfInvalidWriteApiConfiguration(); - this._batchContext.throwIfClosed(); - // do an explicit type safety check here before negating the amount value, - // so we don't unintentionally change the type sent by a user - if (typeof amount !== 'number') { - throw new this._client.ErrorInfo('Counter value decrement should be a number', 40003, 400); - } - - this.increment(-amount); - } -} diff --git a/src/plugins/objects/batchcontextlivemap.ts b/src/plugins/objects/batchcontextlivemap.ts deleted file mode 100644 index 6edacedde2..0000000000 --- a/src/plugins/objects/batchcontextlivemap.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type * as API from '../../../ably'; -import { BatchContext } from './batchcontext'; -import { LiveMap } from './livemap'; -import { LiveObject } from './liveobject'; -import { RealtimeObject } from './realtimeobject'; - -export class BatchContextLiveMap { - constructor( - private _batchContext: BatchContext, - private _realtimeObject: RealtimeObject, - private _map: LiveMap, - ) {} - - get(key: TKey): T[TKey] | undefined { - this._realtimeObject.throwIfInvalidAccessApiConfiguration(); - this._batchContext.throwIfClosed(); - const value = this._map.get(key); - if (value instanceof LiveObject) { - return this._batchContext.getWrappedObject(value.getObjectId()) as T[TKey]; - } else { - return value; - } - } - - size(): number { - this._realtimeObject.throwIfInvalidAccessApiConfiguration(); - this._batchContext.throwIfClosed(); - return this._map.size(); - } - - *entries(): IterableIterator<[TKey, T[TKey]]> { - this._realtimeObject.throwIfInvalidAccessApiConfiguration(); - this._batchContext.throwIfClosed(); - yield* this._map.entries(); - } - - *keys(): IterableIterator { - this._realtimeObject.throwIfInvalidAccessApiConfiguration(); - this._batchContext.throwIfClosed(); - yield* this._map.keys(); - } - - *values(): IterableIterator { - this._realtimeObject.throwIfInvalidAccessApiConfiguration(); - this._batchContext.throwIfClosed(); - yield* this._map.values(); - } - - set(key: TKey, value: T[TKey]): void { - this._realtimeObject.throwIfInvalidWriteApiConfiguration(); - this._batchContext.throwIfClosed(); - const msg = LiveMap.createMapSetMessage(this._realtimeObject, this._map.getObjectId(), key, value); - this._batchContext.queueMessage(msg); - } - - remove(key: TKey): void { - this._realtimeObject.throwIfInvalidWriteApiConfiguration(); - this._batchContext.throwIfClosed(); - const msg = LiveMap.createMapRemoveMessage(this._realtimeObject, this._map.getObjectId(), key); - this._batchContext.queueMessage(msg); - } -} diff --git a/src/plugins/objects/instance.ts b/src/plugins/objects/instance.ts index da9e35f1d2..191401d46c 100644 --- a/src/plugins/objects/instance.ts +++ b/src/plugins/objects/instance.ts @@ -1,10 +1,13 @@ import type BaseClient from 'common/lib/client/baseclient'; import type { AnyInstance, + BatchContext, + BatchFunction, CompactedValue, EventCallback, Instance, InstanceSubscriptionEvent, + LiveObject as LiveObjectType, Primitive, SubscribeResponse, Value, @@ -14,6 +17,7 @@ import { LiveMap } from './livemap'; import { LiveObject } from './liveobject'; import { ObjectMessage } from './objectmessage'; import { RealtimeObject } from './realtimeobject'; +import { RootBatchContext } from './rootbatchcontext'; export interface InstanceEvent { /** Object message that caused this event */ @@ -112,9 +116,12 @@ export class DefaultInstance implements AnyInstance { } *keys>(): IterableIterator { - for (const [key] of this.entries()) { - yield key; + if (!(this._value instanceof LiveMap)) { + // return empty iterator for non-LiveMap objects + return; } + + yield* this._value.keys(); } *values>(): IterableIterator> { @@ -183,4 +190,30 @@ export class DefaultInstance implements AnyInstance { return unsubscribe; }); } + + async batch(fn: BatchFunction): Promise { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); + + if (!(this._value instanceof LiveObject)) { + throw new this._client.ErrorInfo('Cannot batch operations on a non-LiveObject instance', 92007, 400); + } + + const ctx = new RootBatchContext(this._realtimeObject, this); + try { + fn(ctx as unknown as BatchContext); + await ctx.flush(); + } finally { + ctx.close(); + } + } + + /** @internal */ + public isLiveMap(): boolean { + return this._value instanceof LiveMap; + } + + /** @internal */ + public isLiveCounter(): boolean { + return this._value instanceof LiveCounter; + } } diff --git a/src/plugins/objects/livemap.ts b/src/plugins/objects/livemap.ts index 012aaf53f5..91215a6d82 100644 --- a/src/plugins/objects/livemap.ts +++ b/src/plugins/objects/livemap.ts @@ -83,56 +83,11 @@ export class LiveMap extends LiveObject( + static async createMapSetMessage( realtimeObject: RealtimeObject, objectId: string, key: TKey, - value: API.LiveMapType[TKey], - ): ObjectMessage { - const client = realtimeObject.getClient(); - - LiveMap.validateKeyValue(realtimeObject, key, value); - - let objectData: LiveMapObjectData; - if (value instanceof LiveObject) { - const typedObjectData: ObjectIdObjectData = { objectId: value.getObjectId() }; - objectData = typedObjectData; - } else { - const typedObjectData: ValueObjectData = { value: value as PrimitiveObjectValue }; - objectData = typedObjectData; - } - - const msg = ObjectMessage.fromValues( - { - operation: { - action: ObjectOperationAction.MAP_SET, - objectId, - mapOp: { - key, - data: objectData, - }, - } as ObjectOperation, - }, - client.Utils, - client.MessageEncoding, - ); - - return msg; - } - - /** - * Temporary separate method to handle value types as the current Batch API relies on synchronous - * LiveMap.createMapSetMessage() method but object creation is async (need to query timestamp from server). - * TODO: Unify with createMapSetMessage() when Batch API updated to works with new path API and is able to - * defer object creation until batch is committed. - * - * @internal - */ - static async createMapSetMessageForValueType( - realtimeObject: RealtimeObject, - objectId: string, - key: TKey, - value: LiveCounterValueType | LiveMapValueType, + value: API.LiveMapType[TKey] | LiveCounterValueType | LiveMapValueType, ): Promise { const client = realtimeObject.getClient(); @@ -140,13 +95,14 @@ export class LiveMap extends LiveObject extends LiveObject extends LiveObject { this._realtimeObject.throwIfInvalidWriteApiConfiguration(); - - let msgs: ObjectMessage[] = []; - if (LiveCounterValueType.instanceof(value) || LiveMapValueType.instanceof(value)) { - msgs = await LiveMap.createMapSetMessageForValueType(this._realtimeObject, this.getObjectId(), key, value); - } else { - msgs = [LiveMap.createMapSetMessage(this._realtimeObject, this.getObjectId(), key, value)]; - } - + const msgs = await LiveMap.createMapSetMessage(this._realtimeObject, this.getObjectId(), key, value); return this._realtimeObject.publish(msgs); } diff --git a/src/plugins/objects/pathobject.ts b/src/plugins/objects/pathobject.ts index 0114098324..bc5a48c944 100644 --- a/src/plugins/objects/pathobject.ts +++ b/src/plugins/objects/pathobject.ts @@ -2,9 +2,12 @@ import type BaseClient from 'common/lib/client/baseclient'; import type * as API from '../../../ably'; import type { AnyPathObject, + BatchContext, + BatchFunction, CompactedValue, EventCallback, Instance, + LiveObject as LiveObjectType, PathObject, PathObjectSubscriptionEvent, PathObjectSubscriptionOptions, @@ -17,6 +20,7 @@ import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; import { LiveObject } from './liveobject'; import { RealtimeObject } from './realtimeobject'; +import { RootBatchContext } from './rootbatchcontext'; /** * Implementation of AnyPathObject interface. @@ -181,15 +185,7 @@ export class DefaultPathObject implements AnyPathObject { instance(): Instance | undefined { try { - const value = this._resolvePath(this._path); - - if (value instanceof LiveObject) { - // only return an Instance for LiveObject values - return new DefaultInstance(this._realtimeObject, value) as unknown as Instance; - } - - // return undefined for non live objects - return undefined; + return this._resolveInstance(); } catch (error) { if (this._client.Utils.isErrorInfoOrPartialErrorInfo(error) && error.code === 92005) { // ignore path resolution errors and return undefined @@ -231,8 +227,21 @@ export class DefaultPathObject implements AnyPathObject { * Returns an iterator of keys for LiveMap entries */ *keys>(): IterableIterator { - for (const [key] of this.entries()) { - yield key; + try { + const resolved = this._resolvePath(this._path); + if (!(resolved instanceof LiveMap)) { + // return empty iterator for non-LiveMap objects + return; + } + + yield* resolved.keys(); + } catch (error) { + if (this._client.Utils.isErrorInfoOrPartialErrorInfo(error) && error.code === 92005) { + // ignore path resolution errors and return empty iterator + return; + } + // rethrow everything else + throw error; } } @@ -354,6 +363,25 @@ export class DefaultPathObject implements AnyPathObject { }); } + async batch(fn: BatchFunction): Promise { + const instance = this._resolveInstance(); + if (!instance) { + throw new this._client.ErrorInfo( + `Cannot batch operations on a non-LiveObject at path: ${this._escapePath(this._path).join('.')}`, + 92007, + 400, + ); + } + + const ctx = new RootBatchContext(this._realtimeObject, instance); + try { + fn(ctx as unknown as BatchContext); + await ctx.flush(); + } finally { + ctx.close(); + } + } + private _resolvePath(path: string[]): Value { // TODO: remove type assertion when internal LiveMap is updated to support new path based type system let current: Value = this._root as unknown as API.LiveMap; @@ -385,6 +413,18 @@ export class DefaultPathObject implements AnyPathObject { return current; } + private _resolveInstance(): Instance | undefined { + const value = this._resolvePath(this._path); + + if (value instanceof LiveObject) { + // only return an Instance for LiveObject values + return new DefaultInstance(this._realtimeObject, value) as unknown as Instance; + } + + // return undefined for non live objects + return undefined; + } + private _escapePath(path: string[]): string[] { return path.map((x) => x.replace(/\./g, '\\.')); } diff --git a/src/plugins/objects/realtimeobject.ts b/src/plugins/objects/realtimeobject.ts index 93b6478c4a..817e497cbb 100644 --- a/src/plugins/objects/realtimeobject.ts +++ b/src/plugins/objects/realtimeobject.ts @@ -2,7 +2,6 @@ import type BaseClient from 'common/lib/client/baseclient'; import type RealtimeChannel from 'common/lib/client/realtimechannel'; import type EventEmitter from 'common/lib/util/eventemitter'; import type * as API from '../../../ably'; -import { BatchContext } from './batchcontext'; import { DEFAULTS } from './defaults'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; @@ -36,8 +35,6 @@ export interface OnObjectsEventResponse { off(): void; } -export type BatchCallback = (batchContext: BatchContext) => void; - export class RealtimeObject { gcGracePeriod: number; @@ -107,23 +104,6 @@ export class RealtimeObject { return pathObject; } - /** - * Provides access to the synchronous write API for Objects that can be used to batch multiple operations together in a single channel message. - */ - async batch(callback: BatchCallback): Promise { - this.throwIfInvalidWriteApiConfiguration(); - - const root = await this.get(); - const context = new BatchContext(this, root); - - try { - callback(context); - await context.flush(); - } finally { - context.close(); - } - } - on(event: ObjectsEvent, callback: ObjectsEventCallback): OnObjectsEventResponse { // this public API method can be called without specific configuration, so checking for invalid settings is unnecessary. this._eventEmitterPublic.on(event, callback); diff --git a/src/plugins/objects/rootbatchcontext.ts b/src/plugins/objects/rootbatchcontext.ts new file mode 100644 index 0000000000..66927e4462 --- /dev/null +++ b/src/plugins/objects/rootbatchcontext.ts @@ -0,0 +1,73 @@ +import type { Instance, Value } from '../../../ably'; +import { DefaultBatchContext } from './batchcontext'; +import { ObjectMessage } from './objectmessage'; +import { RealtimeObject } from './realtimeobject'; + +export class RootBatchContext extends DefaultBatchContext { + /** Maps object ids to the corresponding batch context wrappers */ + private _wrappedInstances: Map = new Map(); + /** + * Some object messages require asynchronous I/O during construction + * (for example, generating an objectId for nested value types). + * Therefore, messages cannot be constructed immediately during + * synchronous method calls from batch context methods. + * Instead, message constructors are queued and executed on flush. + */ + private _queuedMessageConstructors: (() => Promise)[] = []; + private _isClosed = false; + + constructor(realtimeObject: RealtimeObject, instance: Instance) { + // Pass a placeholder null that will be replaced immediately + super(realtimeObject, instance, null as any); + // Set the root context to itself + this._rootContext = this; + } + + /** @internal */ + async flush(): Promise { + try { + this.close(); + + const msgs = (await Promise.all(this._queuedMessageConstructors.map((x) => x()))).flat(); + + if (msgs.length > 0) { + await this._realtimeObject.publish(msgs); + } + } finally { + this._wrappedInstances.clear(); + this._queuedMessageConstructors = []; + } + } + + /** @internal */ + close(): void { + this._isClosed = true; + } + + /** @internal */ + isClosed(): boolean { + return this._isClosed; + } + + /** @internal */ + wrapInstance(instance: Instance): DefaultBatchContext { + const objectId = instance.id(); + if (objectId) { + // memoize liveobject instances by their object ids + if (this._wrappedInstances.has(objectId)) { + return this._wrappedInstances.get(objectId)!; + } + + let wrappedInstance = new DefaultBatchContext(this._realtimeObject, instance, this); + this._wrappedInstances.set(objectId, wrappedInstance); + return wrappedInstance; + } + + return new DefaultBatchContext(this._realtimeObject, instance, this); + } + + /** @internal */ + queueMessages(msgCtors: () => Promise): void { + this._queuedMessageConstructors.push(msgCtors); + } +} diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js index 03a794b33d..ef2c7a4931 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/objects.test.js @@ -3423,139 +3423,140 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, { - description: 'batch API get method is synchronous', + description: 'DefaultBatchContext.get() returns child DefaultBatchContext instances', action: async (ctx) => { - const { realtimeObject } = ctx; - - await realtimeObject.batch((ctx) => { - const root = ctx.get(); - expect(root, 'Check BatchContext.get() returns object synchronously').to.exist; - expectInstanceOf(root, 'LiveMap', 'root object obtained from a BatchContext is a LiveMap'); - }); - }, - }, - - { - description: 'batch API .get method on a map returns BatchContext* wrappers for objects', - action: async (ctx) => { - const { root, realtimeObject, entryInstance } = ctx; + const { entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(entryInstance, 'counter'), waitForMapKeyUpdate(entryInstance, 'map'), + waitForMapKeyUpdate(entryInstance, 'primitive'), ]); - await root.set('counter', LiveCounter.create(1)); - await root.set('map', LiveMap.create({ innerCounter: LiveCounter.create(1) })); + await entryInstance.set('counter', LiveCounter.create(1)); + await entryInstance.set('map', LiveMap.create({ nestedCounter: LiveCounter.create(1) })); + await entryInstance.set('primitive', 'foo'); await objectsCreatedPromise; - await realtimeObject.batch((ctx) => { - const ctxRoot = ctx.get(); - const ctxCounter = ctxRoot.get('counter'); - const ctxMap = ctxRoot.get('map'); - const ctxInnerCounter = ctxMap.get('innerCounter'); + await entryInstance.batch((ctx) => { + const ctxCounter = ctx.get('counter'); + const ctxMap = ctx.get('map'); + const ctxPrimitive = ctx.get('primitive'); + const ctxNestedCounter = ctxMap.get('nestedCounter'); - expect(ctxCounter, 'Check counter object can be accessed from a map in a batch API').to.exist; + expect(ctxCounter, 'Check counter object can be accessed from a map in a batch context').to.exist; expectInstanceOf( ctxCounter, - 'BatchContextLiveCounter', - 'Check counter object obtained in a batch API has a BatchContext specific wrapper type', + 'DefaultBatchContext', + 'Check counter object obtained in a batch context is of a DefaultBatchContext type', ); - expect(ctxMap, 'Check map object can be accessed from a map in a batch API').to.exist; + expect(ctxMap, 'Check map object can be accessed from a map in a batch context').to.exist; expectInstanceOf( ctxMap, - 'BatchContextLiveMap', - 'Check map object obtained in a batch API has a BatchContext specific wrapper type', + 'DefaultBatchContext', + 'Check map object obtained in a batch context is of a DefaultBatchContext type', + ); + expect(ctxPrimitive, 'Check primitive value can be accessed from a map in a batch context').to.exist; + expectInstanceOf( + ctxPrimitive, + 'DefaultBatchContext', + 'Check primitive value obtained in a batch context is of a DefaultBatchContext type', ); - expect(ctxInnerCounter, 'Check inner counter object can be accessed from a map in a batch API').to.exist; + expect(ctxNestedCounter, 'Check nested counter object can be accessed from a map in a batch context').to + .exist; expectInstanceOf( - ctxInnerCounter, - 'BatchContextLiveCounter', - 'Check inner counter object obtained in a batch API has a BatchContext specific wrapper type', + ctxNestedCounter, + 'DefaultBatchContext', + 'Check nested counter object value obtained in a batch context is of a DefaultBatchContext type', ); }); }, }, { - description: 'batch API access API methods on objects work and are synchronous', + description: 'DefaultBatchContext access API methods on objects work and are synchronous', action: async (ctx) => { - const { root, realtimeObject, entryInstance } = ctx; + const { entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(entryInstance, 'counter'), waitForMapKeyUpdate(entryInstance, 'map'), ]); - await root.set('counter', LiveCounter.create(1)); - await root.set('map', LiveMap.create({ foo: 'bar' })); + await entryInstance.set('counter', LiveCounter.create(1)); + await entryInstance.set('map', LiveMap.create({ foo: 'bar' })); await objectsCreatedPromise; - await realtimeObject.batch((ctx) => { - const ctxRoot = ctx.get(); - const ctxCounter = ctxRoot.get('counter'); - const ctxMap = ctxRoot.get('map'); + await entryInstance.batch((ctx) => { + const ctxCounter = ctx.get('counter'); + const ctxMap = ctx.get('map'); expect(ctxCounter.value()).to.equal( 1, - 'Check batch API counter .value() method works and is synchronous', + 'Check DefaultBatchContext.value() method works for counters and is synchronous', + ); + expect(ctxMap.get('foo').value()).to.equal( + 'bar', + 'Check DefaultBatchContext.get() method works for maps and is synchronous', + ); + expect(ctxMap.size()).to.equal( + 1, + 'Check DefaultBatchContext.size() method works for maps and is synchronous', ); - expect(ctxMap.get('foo')).to.equal('bar', 'Check batch API map .get() method works and is synchronous'); - expect(ctxMap.size()).to.equal(1, 'Check batch API map .size() method works and is synchronous'); - expect([...ctxMap.entries()]).to.deep.equal( + expect([...ctxMap.entries()].map(([key, val]) => [key, val.value()])).to.deep.equal( [['foo', 'bar']], - 'Check batch API map .entries() method works and is synchronous', + 'Check DefaultBatchContext.entries() method works for maps and is synchronous', ); expect([...ctxMap.keys()]).to.deep.equal( ['foo'], - 'Check batch API map .keys() method works and is synchronous', + 'Check DefaultBatchContext.keys() method works for maps and is synchronous', ); - expect([...ctxMap.values()]).to.deep.equal( + expect([...ctxMap.values()].map((x) => x.value())).to.deep.equal( ['bar'], - 'Check batch API map .values() method works and is synchronous', + 'Check DefaultBatchContext.values() method works for maps and is synchronous', ); }); }, }, { - description: 'batch API write API methods on objects do not mutate objects inside the batch callback', + description: + 'DefaultBatchContext write API methods on objects do not mutate objects inside the batch function', action: async (ctx) => { - const { root, realtimeObject, entryInstance } = ctx; + const { entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(entryInstance, 'counter'), waitForMapKeyUpdate(entryInstance, 'map'), ]); - await root.set('counter', LiveCounter.create(1)); - await root.set('map', LiveMap.create({ foo: 'bar' })); + await entryInstance.set('counter', LiveCounter.create(1)); + await entryInstance.set('map', LiveMap.create({ foo: 'bar' })); await objectsCreatedPromise; - await realtimeObject.batch((ctx) => { - const ctxRoot = ctx.get(); - const ctxCounter = ctxRoot.get('counter'); - const ctxMap = ctxRoot.get('map'); + await entryInstance.batch((ctx) => { + const ctxCounter = ctx.get('counter'); + const ctxMap = ctx.get('map'); ctxCounter.increment(10); expect(ctxCounter.value()).to.equal( 1, - 'Check batch API counter .increment method does not mutate the object inside the batch callback', + 'Check DefaultBatchContext.increment() method does not mutate the counter object inside the batch function', ); ctxCounter.decrement(100); expect(ctxCounter.value()).to.equal( 1, - 'Check batch API counter .decrement method does not mutate the object inside the batch callback', + 'Check DefaultBatchContext.decrement() method does not mutate the counter object inside the batch function', ); ctxMap.set('baz', 'qux'); expect( ctxMap.get('baz'), - 'Check batch API map .set method does not mutate the object inside the batch callback', + 'Check DefaultBatchContext.set() method does not mutate the map object inside the batch function', ).to.not.exist; ctxMap.remove('foo'); - expect(ctxMap.get('foo')).to.equal( + expect(ctxMap.get('foo').value()).to.equal( 'bar', - 'Check batch API map .remove method does not mutate the object inside the batch callback', + 'Check DefaultBatchContext.remove() method does not mutate the map object inside the batch function', ); }); }, @@ -3563,25 +3564,21 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { allTransportsAndProtocols: true, - description: 'batch API scheduled operations are applied when batch callback is finished', + description: 'DefaultBatchContext scheduled mutation operations are applied when batch function finishes', action: async (ctx) => { - const { root, realtimeObject, entryInstance } = ctx; + const { entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(entryInstance, 'counter'), waitForMapKeyUpdate(entryInstance, 'map'), ]); - await root.set('counter', LiveCounter.create(1)); - await root.set('map', LiveMap.create({ foo: 'bar' })); + await entryInstance.set('counter', LiveCounter.create(1)); + await entryInstance.set('map', LiveMap.create({ foo: 'bar' })); await objectsCreatedPromise; - const counter = root.get('counter'); - const map = root.get('map'); - - await realtimeObject.batch((ctx) => { - const ctxRoot = ctx.get(); - const ctxCounter = ctxRoot.get('counter'); - const ctxMap = ctxRoot.get('map'); + await entryInstance.batch((ctx) => { + const ctxCounter = ctx.get('counter'); + const ctxMap = ctx.get('map'); ctxCounter.increment(10); ctxCounter.decrement(100); @@ -3590,53 +3587,58 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ctxMap.remove('foo'); }); + const counter = entryInstance.get('counter'); + const map = entryInstance.get('map'); + expect(counter.value()).to.equal(1 + 10 - 100, 'Check counter has an expected value after batch call'); - expect(map.get('baz')).to.equal('qux', 'Check key "baz" has an expected value in a map after batch call'); + expect(map.get('baz').value()).to.equal( + 'qux', + 'Check key "baz" has an expected value in a map after batch call', + ); expect(map.get('foo'), 'Check key "foo" is removed from map after batch call').to.not.exist; }, }, { - description: 'batch API can be called without scheduling any operations', + description: + 'PathObject.batch()/DefaultInstance.batch() can be called without scheduling any mutation operations', action: async (ctx) => { - const { realtimeObject } = ctx; + const { entryPathObject, entryInstance } = ctx; let caughtError; try { - await realtimeObject.batch((ctx) => {}); + await entryPathObject.batch((ctx) => {}); + await entryInstance.batch((ctx) => {}); } catch (error) { caughtError = error; } expect( caughtError, - `Check batch API can be called without scheduling any operations, but got error: ${caughtError?.toString()}`, + `Check batch operation can be called without scheduling any mutation operations, but got error: ${caughtError?.toString()}`, ).to.not.exist; }, }, { - description: 'batch API scheduled operations can be canceled by throwing an error in the batch callback', + description: + 'DefaultBatchContext scheduled mutation operations can be canceled by throwing an error in the batch function', action: async (ctx) => { - const { root, realtimeObject, entryInstance } = ctx; + const { entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(entryInstance, 'counter'), waitForMapKeyUpdate(entryInstance, 'map'), ]); - await root.set('counter', LiveCounter.create(1)); - await root.set('map', LiveMap.create({ foo: 'bar' })); + await entryInstance.set('counter', LiveCounter.create(1)); + await entryInstance.set('map', LiveMap.create({ foo: 'bar' })); await objectsCreatedPromise; - const counter = root.get('counter'); - const map = root.get('map'); - const cancelError = new Error('cancel batch'); let caughtError; try { - await realtimeObject.batch((ctx) => { - const ctxRoot = ctx.get(); - const ctxCounter = ctxRoot.get('counter'); - const ctxMap = ctxRoot.get('map'); + await entryInstance.batch((ctx) => { + const ctxCounter = ctx.get('counter'); + const ctxMap = ctx.get('map'); ctxCounter.increment(10); ctxCounter.decrement(100); @@ -3650,80 +3652,54 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function caughtError = error; } + const counter = entryInstance.get('counter'); + const map = entryInstance.get('map'); + expect(counter.value()).to.equal(1, 'Check counter value is not changed after canceled batch call'); expect(map.get('baz'), 'Check key "baz" does not exist on a map after canceled batch call').to.not.exist; - expect(map.get('foo')).to.equal('bar', 'Check key "foo" is not changed on a map after canceled batch call'); + expect(map.get('foo').value()).to.equal( + 'bar', + 'Check key "foo" is not changed on a map after canceled batch call', + ); expect(caughtError).to.equal( cancelError, - 'Check error from a batch callback was rethrown by a batch method', + 'Check error from a batch function was rethrown by a batch method', ); }, }, { - description: `batch API batch context and derived objects can't be interacted with after the batch call`, + description: `DefaultBatchContext can't be interacted with after batch function finishes`, action: async (ctx) => { - const { root, realtimeObject, entryInstance } = ctx; - - const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(entryInstance, 'counter'), - waitForMapKeyUpdate(entryInstance, 'map'), - ]); - await root.set('counter', LiveCounter.create(1)); - await root.set('map', LiveMap.create({ foo: 'bar' })); - await objectsCreatedPromise; + const { entryInstance } = ctx; let savedCtx; - let savedCtxCounter; - let savedCtxMap; - await realtimeObject.batch((ctx) => { - const ctxRoot = ctx.get(); + await entryInstance.batch((ctx) => { savedCtx = ctx; - savedCtxCounter = ctxRoot.get('counter'); - savedCtxMap = ctxRoot.get('map'); }); - expectAccessBatchApiToThrow({ + expectBatchContextAccessApiToThrow({ ctx: savedCtx, - map: savedCtxMap, - counter: savedCtxCounter, errorMsg: 'Batch is closed', }); - expectWriteBatchApiToThrow({ + expectBatchContextWriteApiToThrow({ ctx: savedCtx, - map: savedCtxMap, - counter: savedCtxCounter, errorMsg: 'Batch is closed', }); }, }, { - description: `batch API batch context and derived objects can't be interacted with after error was thrown from batch callback`, + description: `DefaultBatchContext can't be interacted with after error was thrown from batch function`, action: async (ctx) => { - const { root, realtimeObject, entryInstance } = ctx; - - const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(entryInstance, 'counter'), - waitForMapKeyUpdate(entryInstance, 'map'), - ]); - await root.set('counter', LiveCounter.create(1)); - await root.set('map', LiveMap.create({ foo: 'bar' })); - await objectsCreatedPromise; + const { entryInstance } = ctx; let savedCtx; - let savedCtxCounter; - let savedCtxMap; - let caughtError; try { - await realtimeObject.batch((ctx) => { - const ctxRoot = ctx.get(); + await entryInstance.batch((ctx) => { savedCtx = ctx; - savedCtxCounter = ctxRoot.get('counter'); - savedCtxMap = ctxRoot.get('map'); - throw new Error('cancel batch'); }); } catch (error) { @@ -3731,16 +3707,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function } expect(caughtError, 'Check batch call failed with an error').to.exist; - expectAccessBatchApiToThrow({ + expectBatchContextAccessApiToThrow({ ctx: savedCtx, - map: savedCtxMap, - counter: savedCtxCounter, errorMsg: 'Batch is closed', }); - expectWriteBatchApiToThrow({ + expectBatchContextWriteApiToThrow({ ctx: savedCtx, - map: savedCtxMap, - counter: savedCtxCounter, errorMsg: 'Batch is closed', }); }, @@ -3806,71 +3778,6 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ); }, }, - { - description: `BatchContextLiveMap enumeration`, - action: async (ctx) => { - const { root, objectsHelper, channel, realtimeObject } = ctx; - - const counterId1 = objectsHelper.fakeCounterObjectId(); - const counterId2 = objectsHelper.fakeCounterObjectId(); - await objectsHelper.processObjectStateMessageOnChannel({ - channel, - syncSerial: 'serial:', // empty serial so sync sequence ends immediately - state: [ - objectsHelper.counterObject({ - objectId: counterId1, - siteTimeserials: { - aaa: lexicoTimeserial('aaa', 0, 0), - }, - tombstone: false, - initialCount: 0, - }), - objectsHelper.counterObject({ - objectId: counterId2, - siteTimeserials: { - aaa: lexicoTimeserial('aaa', 0, 0), - }, - tombstone: true, - initialCount: 0, - }), - objectsHelper.mapObject({ - objectId: 'root', - siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, - materialisedEntries: { - counter1: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: counterId1 } }, - counter2: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: counterId2 } }, - foo: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { string: 'bar' } }, - baz: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { string: 'qux' }, tombstone: true }, - }, - }), - ], - }); - - const counter1 = await root.get('counter1'); - - await realtimeObject.batch(async (ctx) => { - const ctxRoot = ctx.get(); - - // enumeration methods should not count tombstoned entries - expect(ctxRoot.size()).to.equal(2, 'Check BatchContextLiveMap.size() returns expected number of keys'); - expect([...ctxRoot.entries()]).to.deep.equal( - [ - ['counter1', counter1], - ['foo', 'bar'], - ], - 'Check BatchContextLiveMap.entries() returns expected entries', - ); - expect([...ctxRoot.keys()]).to.deep.equal( - ['counter1', 'foo'], - 'Check BatchContextLiveMap.keys() returns expected keys', - ); - expect([...ctxRoot.values()]).to.deep.equal( - [counter1, 'bar'], - 'Check BatchContextLiveMap.values() returns expected values', - ); - }); - }, - }, ]; const pathObjectScenarios = [ @@ -4325,6 +4232,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await expectToThrowAsync(async () => nonExistentPathObj.decrement(), errorMsg, { withCode: 92005, }); + await expectToThrowAsync(async () => nonExistentPathObj.batch(), errorMsg, { + withCode: 92005, + }); }, }, @@ -4373,6 +4283,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await expectToThrowAsync(async () => wrongTypePathObj.decrement(), errorMsg, { withCode: 92005, }); + await expectToThrowAsync(async () => wrongTypePathObj.batch(), errorMsg, { + withCode: 92005, + }); }, }, @@ -4464,9 +4377,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await expectToThrowAsync( async () => mapPathObj.decrement(), 'Cannot decrement a non-LiveCounter object at path', - { - withCode: 92007, - }, + { withCode: 92007 }, + ); + + // next mutation methods throw errors for non-LiveObjects + await expectToThrowAsync( + async () => primitivePathObj.batch(), + 'Cannot batch operations on a non-LiveObject at path', + { withCode: 92007 }, ); }, }, @@ -5294,6 +5212,18 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expect(compactValue).to.deep.equal(expected, 'Check complex nested structure is compacted correctly'); }, }, + + { + description: 'PathObject.batch() passes RootBatchContext to its batch function', + action: async (ctx) => { + const { entryPathObject } = ctx; + + await entryPathObject.batch((ctx) => { + expect(ctx, 'Check batch context exists').to.exist; + expectInstanceOf(ctx, 'RootBatchContext', 'Check batch context is of RootBatchContext type'); + }); + }, + }, ]; const instanceScenarios = [ @@ -5691,12 +5621,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await expectToThrowAsync( async () => mapInstance.decrement(), 'Cannot decrement a non-LiveCounter instance', - { - withCode: 92007, - }, + { withCode: 92007 }, ); - // subscription mutation methods throw errors for non-LiveObjects + // next methods throw errors for non-LiveObjects expect(() => { primitiveInstance.subscribe(() => {}); }) @@ -5707,6 +5635,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }) .to.throw('Cannot subscribe to a non-LiveObject instance') .with.property('code', 92007); + await expectToThrowAsync( + async () => primitiveInstance.batch(), + 'Cannot batch operations on a non-LiveObject instance', + { withCode: 92007 }, + ); }, }, @@ -6278,6 +6211,18 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ); }, }, + + { + description: 'DefaultInstance.batch() passes RootBatchContext to its batch function', + action: async (ctx) => { + const { entryInstance } = ctx; + + await entryInstance.batch((ctx) => { + expect(ctx, 'Check batch context exists').to.exist; + expectInstanceOf(ctx, 'RootBatchContext', 'Check batch context is of RootBatchContext type'); + }); + }, + }, ]; /** @nospec */ @@ -7153,8 +7098,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function } }; - const expectWriteApiToThrow = async ({ realtimeObject, map, counter, errorMsg }) => { - await expectToThrowAsync(async () => realtimeObject.batch(), errorMsg); + const expectWriteApiToThrow = async ({ entryInstance, map, counter, errorMsg }) => { + await expectToThrowAsync(async () => entryInstance.batch(), errorMsg); await expectToThrowAsync(async () => counter.increment(), errorMsg); await expectToThrowAsync(async () => counter.decrement(), errorMsg); @@ -7169,46 +7114,42 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }; /** Make sure to call this inside the batch method as batch objects can't be interacted with outside the batch callback */ - const expectAccessBatchApiToThrow = ({ ctx, map, counter, errorMsg }) => { + const expectBatchContextAccessApiToThrow = ({ ctx, errorMsg }) => { expect(() => ctx.get()).to.throw(errorMsg); - - expect(() => counter.value()).to.throw(errorMsg); - - expect(() => map.get()).to.throw(errorMsg); - expect(() => map.size()).to.throw(errorMsg); - expect(() => [...map.entries()]).to.throw(errorMsg); - expect(() => [...map.keys()]).to.throw(errorMsg); - expect(() => [...map.values()]).to.throw(errorMsg); + expect(() => ctx.value()).to.throw(errorMsg); + expect(() => ctx.compact()).to.throw(errorMsg); + expect(() => ctx.id()).to.throw(errorMsg); + expect(() => [...ctx.entries()]).to.throw(errorMsg); + expect(() => [...ctx.keys()]).to.throw(errorMsg); + expect(() => [...ctx.values()]).to.throw(errorMsg); + expect(() => ctx.size()).to.throw(errorMsg); }; /** Make sure to call this inside the batch method as batch objects can't be interacted with outside the batch callback */ - const expectWriteBatchApiToThrow = ({ ctx, map, counter, errorMsg }) => { - expect(() => counter.increment()).to.throw(errorMsg); - expect(() => counter.decrement()).to.throw(errorMsg); - - expect(() => map.set()).to.throw(errorMsg); - expect(() => map.remove()).to.throw(errorMsg); + const expectBatchContextWriteApiToThrow = ({ ctx, errorMsg }) => { + expect(() => ctx.set()).to.throw(errorMsg); + expect(() => ctx.remove()).to.throw(errorMsg); + expect(() => ctx.increment()).to.throw(errorMsg); + expect(() => ctx.decrement()).to.throw(errorMsg); }; const clientConfigurationScenarios = [ { description: 'public API throws missing object modes error when attached without correct modes', action: async (ctx) => { - const { realtimeObject, channel, map, counter } = ctx; + const { realtimeObject, entryInstance, channel, map, counter } = ctx; // obtain batch context with valid modes first - await realtimeObject.batch((ctx) => { - const map = ctx.get().get('map'); - const counter = ctx.get().get('counter'); + await entryInstance.batch((ctx) => { // now simulate missing modes channel.modes = []; - expectAccessBatchApiToThrow({ ctx, map, counter, errorMsg: '"object_subscribe" channel mode' }); - expectWriteBatchApiToThrow({ ctx, map, counter, errorMsg: '"object_publish" channel mode' }); + expectBatchContextAccessApiToThrow({ ctx, errorMsg: '"object_subscribe" channel mode' }); + expectBatchContextWriteApiToThrow({ ctx, errorMsg: '"object_publish" channel mode' }); }); await expectAccessApiToThrow({ realtimeObject, map, counter, errorMsg: '"object_subscribe" channel mode' }); - await expectWriteApiToThrow({ realtimeObject, map, counter, errorMsg: '"object_publish" channel mode' }); + await expectWriteApiToThrow({ entryInstance, map, counter, errorMsg: '"object_publish" channel mode' }); }, }, @@ -7216,41 +7157,37 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'public API throws missing object modes error when not yet attached but client options are missing correct modes', action: async (ctx) => { - const { realtimeObject, channel, map, counter, helper } = ctx; + const { realtimeObject, entryInstance, channel, map, counter, helper } = ctx; // obtain batch context with valid modes first - await realtimeObject.batch((ctx) => { - const map = ctx.get().get('map'); - const counter = ctx.get().get('counter'); + await entryInstance.batch((ctx) => { // now simulate a situation where we're not yet attached/modes are not received on ATTACHED event channel.modes = undefined; helper.recordPrivateApi('write.channel.channelOptions.modes'); channel.channelOptions.modes = []; - expectAccessBatchApiToThrow({ ctx, map, counter, errorMsg: '"object_subscribe" channel mode' }); - expectWriteBatchApiToThrow({ ctx, map, counter, errorMsg: '"object_publish" channel mode' }); + expectBatchContextAccessApiToThrow({ ctx, errorMsg: '"object_subscribe" channel mode' }); + expectBatchContextWriteApiToThrow({ ctx, errorMsg: '"object_publish" channel mode' }); }); await expectAccessApiToThrow({ realtimeObject, map, counter, errorMsg: '"object_subscribe" channel mode' }); - await expectWriteApiToThrow({ realtimeObject, map, counter, errorMsg: '"object_publish" channel mode' }); + await expectWriteApiToThrow({ entryInstance, map, counter, errorMsg: '"object_publish" channel mode' }); }, }, { description: 'public API throws invalid channel state error when channel DETACHED', action: async (ctx) => { - const { realtimeObject, channel, map, counter, helper } = ctx; + const { realtimeObject, entryInstance, channel, map, counter, helper } = ctx; // obtain batch context with valid channel state first - await realtimeObject.batch((ctx) => { - const map = ctx.get().get('map'); - const counter = ctx.get().get('counter'); + await entryInstance.batch((ctx) => { // now simulate channel state change helper.recordPrivateApi('call.channel.requestState'); channel.requestState('detached'); - expectAccessBatchApiToThrow({ ctx, map, counter, errorMsg: 'failed as channel state is detached' }); - expectWriteBatchApiToThrow({ ctx, map, counter, errorMsg: 'failed as channel state is detached' }); + expectBatchContextAccessApiToThrow({ ctx, errorMsg: 'failed as channel state is detached' }); + expectBatchContextWriteApiToThrow({ ctx, errorMsg: 'failed as channel state is detached' }); }); await expectAccessApiToThrow({ @@ -7260,7 +7197,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function errorMsg: 'failed as channel state is detached', }); await expectWriteApiToThrow({ - realtimeObject, + entryInstance, map, counter, errorMsg: 'failed as channel state is detached', @@ -7271,18 +7208,16 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'public API throws invalid channel state error when channel FAILED', action: async (ctx) => { - const { realtimeObject, channel, map, counter, helper } = ctx; + const { realtimeObject, entryInstance, channel, map, counter, helper } = ctx; // obtain batch context with valid channel state first - await realtimeObject.batch((ctx) => { - const map = ctx.get().get('map'); - const counter = ctx.get().get('counter'); + await entryInstance.batch((ctx) => { // now simulate channel state change helper.recordPrivateApi('call.channel.requestState'); channel.requestState('failed'); - expectAccessBatchApiToThrow({ ctx, map, counter, errorMsg: 'failed as channel state is failed' }); - expectWriteBatchApiToThrow({ ctx, map, counter, errorMsg: 'failed as channel state is failed' }); + expectBatchContextAccessApiToThrow({ ctx, errorMsg: 'failed as channel state is failed' }); + expectBatchContextWriteApiToThrow({ ctx, errorMsg: 'failed as channel state is failed' }); }); await expectAccessApiToThrow({ @@ -7292,7 +7227,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function errorMsg: 'failed as channel state is failed', }); await expectWriteApiToThrow({ - realtimeObject, + entryInstance, map, counter, errorMsg: 'failed as channel state is failed', @@ -7303,21 +7238,19 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'public write API throws invalid channel state error when channel SUSPENDED', action: async (ctx) => { - const { realtimeObject, channel, map, counter, helper } = ctx; + const { entryInstance, channel, map, counter, helper } = ctx; // obtain batch context with valid channel state first - await realtimeObject.batch((ctx) => { - const map = ctx.get().get('map'); - const counter = ctx.get().get('counter'); + await entryInstance.batch((ctx) => { // now simulate channel state change helper.recordPrivateApi('call.channel.requestState'); channel.requestState('suspended'); - expectWriteBatchApiToThrow({ ctx, map, counter, errorMsg: 'failed as channel state is suspended' }); + expectBatchContextWriteApiToThrow({ ctx, errorMsg: 'failed as channel state is suspended' }); }); await expectWriteApiToThrow({ - realtimeObject, + entryInstance, map, counter, errorMsg: 'failed as channel state is suspended', @@ -7328,20 +7261,18 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'public write API throws invalid channel option when "echoMessages" is disabled', action: async (ctx) => { - const { realtimeObject, client, map, counter, helper } = ctx; + const { entryInstance, client, map, counter, helper } = ctx; // obtain batch context with valid client options first - await realtimeObject.batch((ctx) => { - const map = ctx.get().get('map'); - const counter = ctx.get().get('counter'); + await entryInstance.batch((ctx) => { // now simulate echoMessages was disabled helper.recordPrivateApi('write.realtime.options.echoMessages'); client.options.echoMessages = false; - expectWriteBatchApiToThrow({ ctx, map, counter, errorMsg: '"echoMessages" client option' }); + expectBatchContextWriteApiToThrow({ ctx, errorMsg: '"echoMessages" client option' }); }); - await expectWriteApiToThrow({ realtimeObject, map, counter, errorMsg: '"echoMessages" client option' }); + await expectWriteApiToThrow({ entryInstance, map, counter, errorMsg: '"echoMessages" client option' }); }, }, ]; @@ -7379,6 +7310,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function channelName, channel, root, + entryPathObject, + entryInstance, map, counter, helper, From 1cf3adbc2083a02f1157473153f3a1a05714b172 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Tue, 11 Nov 2025 04:36:36 +0000 Subject: [PATCH 19/45] Remove BatchContextOperations conditional type and use explicit typings for batch operations TypeScript is not able to correctly resolve the BatchContextOperations conditional type and loses the proper generic constraints for the map type. As a result, operations on a LiveMap inside the batch context fall back to the union type of all possible keys and value types, instead of mapping each specific key to its corresponding value type. See [1] for more details. Additionally, TypeDoc has issues documenting methods that use the BatchContextOperations conditional type - it complains that an implicit __type is not documented, and I was unable to find any way to resolve this. As a result, it is more straightforward to explicitly type all batch context methods rather than trying to make the conditional type work and dealing with the resulting complications. This commit does that. [1] https://stackoverflow.com/questions/69855855/can-i-preserve-generics-in-conditional-types --- ably.d.ts | 212 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 155 insertions(+), 57 deletions(-) diff --git a/ably.d.ts b/ably.d.ts index ef8b254667..17f9e2dc29 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2810,7 +2810,7 @@ interface LiveMapBatchContextCollectionMethods = */ export interface LiveMapBatchContext = Record> extends BatchContextBase, - BatchContextOperations>, + BatchContextLiveMapOperations, LiveMapBatchContextCollectionMethods { /** * Returns the value associated with a given key as a {@link BatchContext}. @@ -2838,7 +2838,7 @@ export interface LiveMapBatchContext = Record { +export interface LiveCounterBatchContext extends BatchContextBase, BatchContextLiveCounterOperations { /** * Get the current value of the counter instance. * If the underlying instance at runtime is not a counter, returns `undefined`. @@ -2932,10 +2932,7 @@ interface AnyBatchContextCollectionMethods { * Each method supports type parameters to specify the expected * underlying type when needed. */ -export interface AnyBatchContext - extends BatchContextBase, - AnyBatchContextCollectionMethods, - BatchContextOperations { +export interface AnyBatchContext extends BatchContextBase, AnyBatchContextCollectionMethods, BatchContextAnyOperations { /** * Navigate to a child entry within the collection by obtaining the {@link BatchContext} at that entry. * The entry in a collection is identified with a string key. @@ -2973,20 +2970,6 @@ export interface AnyBatchContext compact(): CompactedValue | undefined; } -/** - * BatchContextOperations transforms LiveObject operation methods to be synchronous and removes the `batch` method. - */ -type BatchContextOperations = { - [K in keyof T as K extends 'batch' ? never : K]: T[K] extends ( - this: infer This, - ...args: infer A - ) => PromiseLike - ? (this: This, ...args: A) => R - : T[K] extends (this: infer This, ...args: infer A) => infer R - ? (this: This, ...args: A) => R - : T[K]; -}; - /** * BatchContext wraps a specific object instance or entry in a specific collection * object instance and provides synchronous operation methods that can be aggregated @@ -3005,6 +2988,139 @@ export type BatchContext = [T] extends [LiveMap] ? PrimitiveBatchContext : AnyBatchContext; +/** + * Defines operations available on {@link LiveMapBatchContext}. + */ +export interface BatchContextLiveMapOperations = Record> { + /** + * Adds an operation to the current batch to set a key to a specified value on the underlying + * {@link LiveMapInstance}. All queued operations are sent together in a single message once the + * batch function completes. + * + * If the underlying instance at runtime is not a map, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to set the value for. + * @param value - The value to assign to the key. + * @experimental + */ + set(key: K, value: T[K]): void; + + /** + * Adds an operation to the current batch to remove a key from the underlying + * {@link LiveMapInstance}. All queued operations are sent together in a single message once the + * batch function completes. + * + * If the underlying instance at runtime is not a map, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to remove. + * @experimental + */ + remove(key: keyof T & string): void; +} + +/** + * Defines operations available on {@link LiveCounterBatchContext}. + */ +export interface BatchContextLiveCounterOperations { + /** + * Adds an operation to the current batch to increment the value of the underlying + * {@link LiveCounterInstance}. All queued operations are sent together in a single message once the + * batch function completes. + * + * If the underlying instance at runtime is not a counter, this method throws an error. + * + * This does not modify the underlying data of the counter. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. + * @experimental + */ + increment(amount?: number): void; + + /** + * An alias for calling {@link BatchContextLiveCounterOperations.increment | increment(-amount)} + * + * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. + * @experimental + */ + decrement(amount?: number): void; +} + +/** + * Defines all possible operations available on {@link BatchContext} objects. + */ +export interface BatchContextAnyOperations { + // LiveMap operations + + /** + * Adds an operation to the current batch to set a key to a specified value on the underlying + * {@link LiveMapInstance}. All queued operations are sent together in a single message once the + * batch function completes. + * + * If the underlying instance at runtime is not a map, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to set the value for. + * @param value - The value to assign to the key. + * @experimental + */ + set = Record>(key: keyof T & string, value: T[keyof T]): void; + + /** + * Adds an operation to the current batch to remove a key from the underlying + * {@link LiveMapInstance}. All queued operations are sent together in a single message once the + * batch function completes. + * + * If the underlying instance at runtime is not a map, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to remove. + * @experimental + */ + remove = Record>(key: keyof T & string): void; + + // LiveCounter operations + + /** + * Adds an operation to the current batch to increment the value of the underlying + * {@link LiveCounterInstance}. All queued operations are sent together in a single message once the + * batch function completes. + * + * If the underlying instance at runtime is not a counter, this method throws an error. + * + * This does not modify the underlying data of the counter. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. + * @experimental + */ + increment(amount?: number): void; + + /** + * An alias for calling {@link BatchContextAnyOperations.increment | increment(-amount)} + * + * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. + * @experimental + */ + decrement(amount?: number): void; +} + /** * Defines batch operations available on {@link LiveObject | LiveObjects}. */ @@ -3038,12 +3154,9 @@ export interface LiveMapOperations = Record = Record { * Sends an operation to the Ably system to increment the value of a given {@link LiveCounterInstance}, * or of the counter instance resolved from the path when using {@link LiveCounterPathObject}. * - * If called from within the {@link BatchOperations.batch | batch} method, the operation is instead - * added to the current batch and sent once the batch function completes. - * - * If called via {@link LiveCounterInstance} or {@link LiveCounterBatchContext} and the underlying instance - * at runtime is not a counter, or if called via {@link LiveCounterPathObject} and the counter instance - * at the specified path cannot be resolved at the time of the call, this method throws an error. + * If called via {@link LiveCounterInstance} and the underlying instance at runtime is not a counter, + * or if called via {@link LiveCounterPathObject} and the counter instance at the specified path cannot + * be resolved at the time of the call, this method throws an error. * * This does not modify the underlying data of the counter. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. @@ -3114,7 +3221,7 @@ export interface LiveCounterOperations extends BatchOperations { } /** - * Defines all possible operations available on an {@link AnyPathObject}. + * Defines all possible operations available on {@link LiveObject | LiveObjects}. */ export interface AnyOperations { /** @@ -3142,12 +3249,9 @@ export interface AnyOperations { * Sends an operation to the Ably system to set a key to a specified value on the underlying map when using {@link AnyInstance}, * or on the map instance resolved from the path when using {@link AnyPathObject}. * - * If called from within the {@link BatchOperations.batch | batch} method, the operation is instead - * added to the current batch and sent once the batch function completes. - * - * If called via {@link AnyInstance} or {@link AnyBatchContext} and the underlying instance - * at runtime is not a map, or if called via {@link AnyPathObject} and the map instance - * at the specified path cannot be resolved at the time of the call, this method throws an error. + * If called via {@link AnyInstance} and the underlying instance at runtime is not a map, + * or if called via {@link AnyPathObject} and the map instance at the specified path cannot + * be resolved at the time of the call, this method throws an error. * * This does not modify the underlying data of the map. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. @@ -3164,12 +3268,9 @@ export interface AnyOperations { * Sends an operation to the Ably system to remove a key from the underlying map when using {@link AnyInstance}, * or from the map instance resolved from the path when using {@link AnyPathObject}. * - * If called from within the {@link BatchOperations.batch | batch} method, the operation is instead - * added to the current batch and sent once the batch function completes. - * - * If called via {@link AnyInstance} or {@link AnyBatchContext} and the underlying instance - * at runtime is not a map, or if called via {@link AnyPathObject} and the map instance - * at the specified path cannot be resolved at the time of the call, this method throws an error. + * If called via {@link AnyInstance} and the underlying instance at runtime is not a map, + * or if called via {@link AnyPathObject} and the map instance at the specified path cannot + * be resolved at the time of the call, this method throws an error. * * This does not modify the underlying data of the map. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. @@ -3187,12 +3288,9 @@ export interface AnyOperations { * Sends an operation to the Ably system to increment the value of the underlying counter when using {@link AnyInstance}, * or of the counter instance resolved from the path when using {@link AnyPathObject}. * - * If called from within the {@link BatchOperations.batch | batch} method, the operation is instead - * added to the current batch and sent once the batch function completes. - * - * If called via {@link AnyInstance} or {@link AnyBatchContext} and the underlying instance - * at runtime is not a counter, or if called via {@link AnyPathObject} and the counter instance - * at the specified path cannot be resolved at the time of the call, this method throws an error. + * If called via {@link AnyInstance} and the underlying instance at runtime is not a counter, + * or if called via {@link AnyPathObject} and the counter instance at the specified path cannot + * be resolved at the time of the call, this method throws an error. * * This does not modify the underlying data of the counter. Instead, the change is applied when * the published operation is echoed back to the client and applied to the object. From ba2d842e0e6d83f317d4af7a5bf9130b362a0e6e Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Tue, 11 Nov 2025 05:51:07 +0000 Subject: [PATCH 20/45] Remove non-path-based API from LiveObjects This marks full transition to path-based API for LiveObjects. Included in this PR: - resolve TODOs related to path-based API - update all LiveObjects tests to use path-based API - remove publicly exposed LiveObject, LiveMap and LiveCounter types from ably.d.ts - remove lifecycle subscriptions API for liveobjects (API and implementation). OBJECT_DELETE events are handled by regular subscription events. - update package tests to use the new path-based API for Objects --- ably.d.ts | 372 ++----- src/plugins/objects/batchcontext.ts | 2 +- src/plugins/objects/instance.ts | 6 +- src/plugins/objects/livemap.ts | 59 +- src/plugins/objects/livemapvaluetype.ts | 8 +- src/plugins/objects/liveobject.ts | 48 +- src/plugins/objects/objectmessage.ts | 4 +- src/plugins/objects/objectspool.ts | 5 +- src/plugins/objects/pathobject.ts | 10 +- .../objects/pathobjectsubscriptionregister.ts | 4 +- src/plugins/objects/realtimeobject.ts | 15 +- test/common/modules/private_api_recorder.js | 1 - .../browser/template/src/index-objects.ts | 69 +- test/realtime/objects.test.js | 944 ++++++++---------- 14 files changed, 577 insertions(+), 970 deletions(-) diff --git a/ably.d.ts b/ably.d.ts index 17f9e2dc29..57c1e6ddb1 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -1657,11 +1657,6 @@ export type EventCallback = (event: T) => void; */ export type ObjectsEventCallback = () => void; -/** - * The callback used for the lifecycle events emitted by {@link LiveObjectDeprecated}. - */ -export type LiveObjectLifecycleEventCallback = () => void; - /** * A function passed to the {@link BatchOperations.batch | batch} method to group multiple Objects operations into a single channel message. * @@ -1671,6 +1666,50 @@ export type LiveObjectLifecycleEventCallback = () => void; */ export type BatchFunction = (ctx: BatchContext) => void; +/** + * Represents a subscription that can be unsubscribed from. + * This interface provides a way to clean up and remove subscriptions when they are no longer needed. + * + * @example + * ```typescript + * const s = someService.subscribe(); + * // Later when done with the subscription + * s.unsubscribe(); + * ``` + */ +export interface Subscription { + /** + * Deregisters the listener previously passed to the `subscribe` method. + * + * This method should be called when the subscription is no longer needed, + * it will make sure no further events will be sent to the subscriber and + * that references to the subscriber are cleaned up. + */ + readonly unsubscribe: () => void; +} + +/** + * Represents a subscription to status change events that can be unsubscribed from. + * This interface provides a way to clean up and remove subscriptions when they are no longer needed. + * + * @example + * ```typescript + * const s = someService.on(); + * // Later when done with the subscription + * s.off(); + * ``` + */ +export interface StatusSubscription { + /** + * Deregisters the listener previously passed to the `on` method. + * + * Unsubscribes from the status change events. It will ensure that no + * further status change events will be sent to the subscriber and + * that references to the subscriber are cleaned up. + */ + readonly off: () => void; +} + // Internal Interfaces // To allow a uniform (callback) interface between on and once even in the @@ -2268,39 +2307,24 @@ declare namespace ObjectsEvents { */ export type ObjectsEvent = ObjectsEvents.SYNCED | ObjectsEvents.SYNCING; -/** - * The `LiveObjectLifecycleEvents` namespace describes the possible values of the {@link LiveObjectLifecycleEvent} type. - */ -declare namespace LiveObjectLifecycleEvents { - /** - * Indicates that the object has been deleted from the Objects pool and should no longer be interacted with. - */ - type DELETED = 'deleted'; -} - -/** - * Describes the events emitted by a {@link LiveObjectDeprecated} object. - */ -export type LiveObjectLifecycleEvent = LiveObjectLifecycleEvents.DELETED; - /** * Enables the Objects to be read, modified and subscribed to for a channel. */ export declare interface RealtimeObject { /** - * Retrieves the {@link LiveMapDeprecated} object - the entrypoint for Objects on a channel. + * Retrieves a {@link PathObject} for the object on a channel. * * A type parameter can be provided to describe the structure of the Objects on the channel. By default, it uses types from the globally defined `AblyObjectsTypes` interface. * - * You can specify custom types for Objects by defining a global `AblyObjectsTypes` interface with a `object` property that conforms to {@link LiveMapType}. + * You can specify custom types for Objects by defining a global `AblyObjectsTypes` interface with an `object` property that conforms to Record type. * * Example: * * ```typescript - * import { LiveCounter } from 'ably'; + * import { LiveCounter } from 'ably/objects'; * * type MyObject = { - * myTypedKey: LiveCounter; + * myTypedCounter: LiveCounter; * }; * * declare global { @@ -2310,26 +2334,20 @@ export declare interface RealtimeObject { * } * ``` * - * @returns A promise which, upon success, will be fulfilled with a {@link LiveMapDeprecated} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + * @returns A promise which, upon success, will be fulfilled with a {@link PathObject}. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. * @experimental */ - get(): Promise>; - - /** - * TODO: replace .get call with this one when we have full path object API support. - * temporary keep this and regular .get so we can have tests running against both. - */ - getPathObject>(): Promise>>; + get = AblyDefaultObject>(): Promise>>; /** * Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. * * @param event - The named event to listen for. * @param callback - The event listener. - * @returns A {@link OnObjectsEventResponse} object that allows the provided listener to be deregistered from future updates. + * @returns A {@link StatusSubscription} object that allows the provided listener to be deregistered from future updates. * @experimental */ - on(event: ObjectsEvent, callback: ObjectsEventCallback): OnObjectsEventResponse; + on(event: ObjectsEvent, callback: ObjectsEventCallback): StatusSubscription; /** * Removes all registrations that match both the specified listener and the specified event. @@ -2362,6 +2380,26 @@ export type Primitive = | JsonArray | JsonObject; +/** + * Represents a JSON-encodable value. + */ +export type Json = JsonScalar | JsonArray | JsonObject; + +/** + * Represents a JSON-encodable scalar value. + */ +export type JsonScalar = null | boolean | number | string; + +/** + * Represents a JSON-encodable array. + */ +export type JsonArray = Json[]; + +/** + * Represents a JSON-encodable object. + */ +export type JsonObject = { [prop: string]: Json | undefined }; + /** * Unique symbol for nominal typing within TypeScript's structural type system. * This prevents structural compatibility between LiveObject types. @@ -2430,6 +2468,31 @@ export type CompactedValue = ? T : any; +declare global { + /** + * A globally defined interface that allows users to define custom types for Objects. + */ + export interface AblyObjectsTypes { + [key: string]: unknown; + } +} + +/** + * The default type for the channel object return from the {@link RealtimeObject.get}, based on the globally defined {@link AblyObjectsTypes} interface. + * + * - If no custom types are provided in `AblyObjectsTypes`, defaults to an untyped map representation using the Record type. + * - If an `object` key exists in `AblyObjectsTypes` and its type conforms to the Record, it is used as the type for the object returned from the {@link RealtimeObject.get}. + * - If the provided type in `object` key does not match Record, a type error message is returned. + */ +export type AblyDefaultObject = + // we need a way to know when no types were provided by the user. + // we expect an "object" property to be set on AblyObjectsTypes interface, e.g. it won't be "unknown" anymore + unknown extends AblyObjectsTypes['object'] + ? Record // no custom types provided; use the default untyped map representation for the entrypoint map + : AblyObjectsTypes['object'] extends Record + ? AblyObjectsTypes['object'] // "object" property exists, and it is of an expected type, we can use this interface for the entrypoint map + : `Provided type definition for the channel \`object\` in AblyObjectsTypes is not of an expected Record type`; + /** * PathObjectBase defines the set of common methods on a PathObject * that are present regardless of the underlying type. @@ -2461,13 +2524,13 @@ interface PathObjectBase { * * @param listener - An event listener function. * @param options - Optional subscription configuration. - * @returns A {@link SubscribeResponse} object that allows the provided listener to be deregistered from future updates. + * @returns A {@link Subscription} object that allows the provided listener to be deregistered from future updates. * @experimental */ subscribe( listener: EventCallback, options?: PathObjectSubscriptionOptions, - ): SubscribeResponse; + ): Subscription; /** * Registers a subscription listener and returns an async iterator that yields @@ -3312,23 +3375,6 @@ export interface AnyOperations { decrement(amount?: number): Promise; } -declare global { - /** - * A globally defined interface that allows users to define custom types for Objects. - */ - export interface AblyObjectsTypes { - [key: string]: unknown; - } -} - -/** - * Represents the type of data stored in a {@link LiveMapDeprecated}. - * It maps string keys to primitive values ({@link PrimitiveObjectValue}), or other {@link LiveObjectDeprecated | LiveObjects}. - */ -export type LiveMapType = { - [key: string]: PrimitiveObjectValue | LiveMapDeprecated | LiveCounterDeprecated | undefined; -}; - /** * InstanceBase defines the set of common methods on an Instance * that are present regardless of the underlying type specified in the type parameter T. @@ -3360,10 +3406,10 @@ interface InstanceBase { * automatically deregistering. * * @param listener - An event listener function. - * @returns A {@link SubscribeResponse} object that allows the provided listener to be deregistered from future updates. + * @returns A {@link Subscription} object that allows the provided listener to be deregistered from future updates. * @experimental */ - subscribe(listener: EventCallback>): SubscribeResponse; + subscribe(listener: EventCallback>): Subscription; /** * Registers a subscription listener and returns an async iterator that yields @@ -3838,222 +3884,6 @@ export interface ObjectData { value?: Primitive; } -/** - * The default type for the entrypoint {@link LiveMapDeprecated} object on a channel, based on the globally defined {@link AblyObjectsTypes} interface. - * - * - If no custom types are provided in `AblyObjectsTypes`, defaults to an untyped map representation using the {@link LiveMapType} interface. - * - If an `object` key exists in `AblyObjectsTypes` and its type conforms to the {@link LiveMapType} interface, it is used as the type for the entrypoint {@link LiveMapDeprecated} object. - * - If the provided type in `object` key does not match {@link LiveMapType}, a type error message is returned. - */ -export type AblyDefaultObject = - // we need a way to know when no types were provided by the user. - // we expect an "object" property to be set on AblyObjectsTypes interface, e.g. it won't be "unknown" anymore - unknown extends AblyObjectsTypes['object'] - ? LiveMapType // no custom types provided; use the default untyped map representation for the entrypoint map - : AblyObjectsTypes['object'] extends LiveMapType - ? AblyObjectsTypes['object'] // "object" property exists, and it is of an expected type, we can use this interface for the entrypoint map - : `Provided type definition for the channel \`object\` in AblyObjectsTypes is not of an expected LiveMapType`; - -/** - * Object returned from an `on` call, allowing the listener provided in that call to be deregistered. - */ -export declare interface OnObjectsEventResponse { - /** - * Deregisters the listener passed to the `on` call. - * - * @experimental - */ - off(): void; -} - -/** - * The `LiveMap` class represents a key-value map data structure, similar to a JavaScript Map, where all changes are synchronized across clients in realtime. - * Conflicts in a LiveMap are automatically resolved with last-write-wins (LWW) semantics, - * meaning that if two clients update the same key in the map, the update with the most recent timestamp wins. - * - * Keys must be strings. Values can be another {@link LiveObjectDeprecated}, or a primitive type, such as a string, number, boolean, JSON-serializable object or array, or binary data (see {@link PrimitiveObjectValue}). - */ -export declare interface LiveMapDeprecated extends LiveObjectDeprecated { - /** - * Returns the value associated with a given key. Returns `undefined` if the key doesn't exist in a map or if the associated {@link LiveObjectDeprecated} has been deleted. - * - * Always returns undefined if this map object is deleted. - * - * @param key - The key to retrieve the value for. - * @returns A {@link LiveObjectDeprecated}, a primitive type (string, number, boolean, JSON-serializable object or array, or binary data) or `undefined` if the key doesn't exist in a map or the associated {@link LiveObjectDeprecated} has been deleted. Always `undefined` if this map object is deleted. - * @experimental - */ - get(key: TKey): T[TKey] | undefined; - - /** - * Returns the number of key-value pairs in the map. - * - * @experimental - */ - size(): number; - - /** - * Returns an iterable of key-value pairs for every entry in the map. - * - * @experimental - */ - entries(): IterableIterator<[TKey, T[TKey]]>; - - /** - * Returns an iterable of keys in the map. - * - * @experimental - */ - keys(): IterableIterator; - - /** - * Returns an iterable of values in the map. - * - * @experimental - */ - values(): IterableIterator; - - /** - * Sends an operation to the Ably system to set a key on this `LiveMap` object to a specified value. - * - * This does not modify the underlying data of this object. Instead, the change is applied when - * the published operation is echoed back to the client and applied to the object. - * - * @param key - The key to set the value for. - * @param value - The value to assign to the key. - * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental - */ - set(key: TKey, value: T[TKey]): Promise; - - /** - * Sends an operation to the Ably system to remove a key from this `LiveMap` object. - * - * This does not modify the underlying data of this object. Instead, the change is applied when - * the published operation is echoed back to the client and applied to the object. - * - * @param key - The key to remove. - * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental - */ - remove(key: TKey): Promise; -} - -/** - * Represents a primitive value that can be stored in a {@link LiveMapDeprecated}. - * - * For binary data, the resulting type depends on the platform (`Buffer` in Node.js, `ArrayBuffer` elsewhere). - */ -export type PrimitiveObjectValue = string | number | boolean | Buffer | ArrayBuffer | JsonArray | JsonObject; - -/** - * Represents a JSON-encodable value. - */ -export type Json = JsonScalar | JsonArray | JsonObject; - -/** - * Represents a JSON-encodable scalar value. - */ -export type JsonScalar = null | boolean | number | string; - -/** - * Represents a JSON-encodable array. - */ -export type JsonArray = Json[]; - -/** - * Represents a JSON-encodable object. - */ -export type JsonObject = { [prop: string]: Json | undefined }; - -/** - * The `LiveCounter` class represents a counter that can be incremented or decremented and is synchronized across clients in realtime. - */ -export declare interface LiveCounterDeprecated extends LiveObjectDeprecated { - /** - * Returns the current value of the counter. - * - * @experimental - */ - value(): number; - - /** - * Sends an operation to the Ably system to increment the value of this `LiveCounter` object. - * - * This does not modify the underlying data of this object. Instead, the change is applied when - * the published operation is echoed back to the client and applied to the object. - * - * @param amount - The amount by which to increase the counter value. - * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental - */ - increment(amount: number): Promise; - - /** - * An alias for calling {@link LiveCounterDeprecated.increment | LiveCounter.increment(-amount)} - * - * @param amount - The amount by which to decrease the counter value. - * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental - */ - decrement(amount: number): Promise; -} - -/** - * Describes the common interface for all conflict-free data structures supported by the Objects. - */ -export declare interface LiveObjectDeprecated { - /** - * Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. - * - * @param event - The named event to listen for. - * @param callback - The event listener. - * @returns A {@link OnLiveObjectLifecycleEventResponse} object that allows the provided listener to be deregistered from future updates. - * @experimental - */ - on(event: LiveObjectLifecycleEvent, callback: LiveObjectLifecycleEventCallback): OnLiveObjectLifecycleEventResponse; - - /** - * Removes all registrations that match both the specified listener and the specified event. - * - * @param event - The named event. - * @param callback - The event listener. - * @experimental - */ - off(event: LiveObjectLifecycleEvent, callback: LiveObjectLifecycleEventCallback): void; - - /** - * Deregisters all registrations, for all events and listeners. - * - * @experimental - */ - offAll(): void; -} - -/** - * Object returned from a `subscribe` call, allowing the listener provided in that call to be deregistered. - */ -export declare interface SubscribeResponse { - /** - * Deregisters the listener passed to the `subscribe` call. - * - * @experimental - */ - unsubscribe(): void; -} - -/** - * Object returned from an `on` call, allowing the listener provided in that call to be deregistered. - */ -export declare interface OnLiveObjectLifecycleEventResponse { - /** - * Deregisters the listener passed to the `on` call. - * - * @experimental - */ - off(): void; -} - /** * Enables messages to be published and historic messages to be retrieved for a channel. */ diff --git a/src/plugins/objects/batchcontext.ts b/src/plugins/objects/batchcontext.ts index b9b8bafc15..dee40c9a21 100644 --- a/src/plugins/objects/batchcontext.ts +++ b/src/plugins/objects/batchcontext.ts @@ -81,7 +81,7 @@ export class DefaultBatchContext implements AnyBatchContext { throw new this._client.ErrorInfo('Cannot set a key on a non-LiveMap instance', 92007, 400); } this._rootContext.queueMessages(async () => - LiveMap.createMapSetMessage(this._realtimeObject, this._instance.id()!, key, value as Primitive), + LiveMap.createMapSetMessage(this._realtimeObject, this._instance.id()!, key, value), ); } diff --git a/src/plugins/objects/instance.ts b/src/plugins/objects/instance.ts index 191401d46c..97fbdcbec6 100644 --- a/src/plugins/objects/instance.ts +++ b/src/plugins/objects/instance.ts @@ -9,7 +9,7 @@ import type { InstanceSubscriptionEvent, LiveObject as LiveObjectType, Primitive, - SubscribeResponse, + Subscription, Value, } from '../../../ably'; import { LiveCounter } from './livecounter'; @@ -169,11 +169,11 @@ export class DefaultInstance implements AnyInstance { return this._value.decrement(amount ?? 1); } - subscribe(listener: EventCallback>): SubscribeResponse { + subscribe(listener: EventCallback>): Subscription { if (!(this._value instanceof LiveObject)) { throw new this._client.ErrorInfo('Cannot subscribe to a non-LiveObject instance', 92007, 400); } - return this._value.instanceSubscribe((event: InstanceEvent) => { + return this._value.subscribe((event: InstanceEvent) => { listener({ object: this as unknown as Instance, message: event.message?.toUserFacingMessage(this._realtimeObject.getChannel()), diff --git a/src/plugins/objects/livemap.ts b/src/plugins/objects/livemap.ts index 91215a6d82..1bf9ac4b6d 100644 --- a/src/plugins/objects/livemap.ts +++ b/src/plugins/objects/livemap.ts @@ -13,7 +13,6 @@ import { ObjectsMapEntry, ObjectsMapOp, ObjectsMapSemantics, - PrimitiveObjectValue, } from './objectmessage'; import { RealtimeObject } from './realtimeobject'; @@ -24,7 +23,7 @@ export interface ObjectIdObjectData { export interface ValueObjectData { /** A decoded leaf value from {@link WireObjectData}. */ - value: string | number | boolean | Buffer | ArrayBuffer | API.JsonArray | API.JsonObject; + value: API.Primitive; } export type LiveMapObjectData = ObjectIdObjectData | ValueObjectData; @@ -40,13 +39,18 @@ export interface LiveMapData extends LiveObjectData { data: Map; // RTLM3 } -export interface LiveMapUpdate extends LiveObjectUpdate { +export interface LiveMapUpdate> extends LiveObjectUpdate { update: { [keyName in keyof T & string]?: 'updated' | 'removed' }; _type: 'LiveMapUpdate'; } /** @spec RTLM1, RTLM2 */ -export class LiveMap extends LiveObject> { +export class LiveMap = Record> + extends LiveObject> + implements API.LiveMap +{ + declare readonly [API.__livetype]: 'LiveMap'; // type-only, unique symbol to satisfy branded interfaces, no JS emitted + constructor( realtimeObject: RealtimeObject, private _semantics: ObjectsMapSemantics, @@ -61,8 +65,8 @@ export class LiveMap extends LiveObject(realtimeObject: RealtimeObject, objectId: string): LiveMap { - return new LiveMap(realtimeObject, ObjectsMapSemantics.LWW, objectId); + static zeroValue(realtimeObject: RealtimeObject, objectId: string): LiveMap { + return new LiveMap(realtimeObject, ObjectsMapSemantics.LWW, objectId); } /** @@ -71,11 +75,8 @@ export class LiveMap extends LiveObject( - realtimeObject: RealtimeObject, - objectMessage: ObjectMessage, - ): LiveMap { - const obj = new LiveMap(realtimeObject, objectMessage.object!.map!.semantics!, objectMessage.object!.objectId); + static fromObjectState(realtimeObject: RealtimeObject, objectMessage: ObjectMessage): LiveMap { + const obj = new LiveMap(realtimeObject, objectMessage.object!.map!.semantics!, objectMessage.object!.objectId); obj.overrideWithObjectState(objectMessage); return obj; } @@ -83,11 +84,11 @@ export class LiveMap extends LiveObject( + static async createMapSetMessage( realtimeObject: RealtimeObject, objectId: string, - key: TKey, - value: API.LiveMapType[TKey] | LiveCounterValueType | LiveMapValueType, + key: string, + value: API.Value, ): Promise { const client = realtimeObject.getClient(); @@ -111,12 +112,8 @@ export class LiveMap extends LiveObject extends LiveObject( - realtimeObject: RealtimeObject, - objectId: string, - key: TKey, - ): ObjectMessage { + static createMapRemoveMessage(realtimeObject: RealtimeObject, objectId: string, key: string): ObjectMessage { const client = realtimeObject.getClient(); if (typeof key !== 'string') { @@ -170,11 +163,7 @@ export class LiveMap extends LiveObject( - realtimeObject: RealtimeObject, - key: TKey, - value: API.LiveMapType[TKey] | LiveCounterValueType | LiveMapValueType, - ): void { + static validateKeyValue(realtimeObject: RealtimeObject, key: string, value: API.Value): void { const client = realtimeObject.getClient(); if (typeof key !== 'string') { @@ -209,19 +198,19 @@ export class LiveMap extends LiveObject extends LiveObject> { const result: Record = {} as Record; // Use public entries() method to ensure we only include publicly exposed properties @@ -911,7 +900,7 @@ export class LiveMap extends LiveObject extends LiveObject = Record - LiveMap.validateKeyValue(realtimeObject, key, value as any), - ); + Object.entries(entries ?? {}).forEach(([key, value]) => LiveMap.validateKeyValue(realtimeObject, key, value)); const { initialValueOperation, nestedObjectsCreateMsgs } = await LiveMapValueType._createInitialValueOperation( realtimeObject, @@ -142,7 +138,7 @@ export class LiveMapValueType = Record void; - -export interface OnLiveObjectLifecycleEventResponse { - off(): void; -} - export abstract class LiveObject< TData extends LiveObjectData = LiveObjectData, TUpdate extends LiveObjectUpdate = LiveObjectUpdate, > { protected _client: BaseClient; protected _subscriptions: EventEmitter; - protected _lifecycleEvents: EventEmitter; protected _objectId: string; /** * Represents an aggregated value for an object, which combines the initial value for an object from the create operation, @@ -71,7 +59,6 @@ export abstract class LiveObject< ) { this._client = this._realtimeObject.getClient(); this._subscriptions = new this._client.EventEmitter(this._client.logger); - this._lifecycleEvents = new this._client.EventEmitter(this._client.logger); this._objectId = objectId; this._dataRef = this._getZeroValueData(); // use empty map of serials by default, so any future operation can be applied to this object @@ -81,7 +68,7 @@ export abstract class LiveObject< this._parentReferences = new Map>(); } - instanceSubscribe(listener: EventCallback): SubscribeResponse { + subscribe(listener: EventCallback): Subscription { this._realtimeObject.throwIfInvalidAccessApiConfiguration(); this._subscriptions.on(LiveObjectSubscriptionEvent.updated, listener); @@ -93,33 +80,6 @@ export abstract class LiveObject< return { unsubscribe }; } - on(event: LiveObjectLifecycleEvent, callback: LiveObjectLifecycleEventCallback): OnLiveObjectLifecycleEventResponse { - // this public API method can be called without specific configuration, so checking for invalid settings is unnecessary. - this._lifecycleEvents.on(event, callback); - - const off = () => { - this._lifecycleEvents.off(event, callback); - }; - - return { off }; - } - - off(event: LiveObjectLifecycleEvent, callback: LiveObjectLifecycleEventCallback): void { - // this public API method can be called without specific configuration, so checking for invalid settings is unnecessary. - - // prevent accidentally calling .off without any arguments on an EventEmitter and removing all callbacks - if (this._client.Utils.isNil(event) && this._client.Utils.isNil(callback)) { - return; - } - - this._lifecycleEvents.off(event, callback); - } - - offAll(): void { - // this public API method can be called without specific configuration, so checking for invalid settings is unnecessary. - this._lifecycleEvents.off(); - } - /** * @internal */ @@ -170,8 +130,6 @@ export abstract class LiveObject< update.objectMessage = objectMessage; update.tombstone = true; - this._lifecycleEvents.emit(LiveObjectLifecycleEvent.deleted); - return update; } @@ -347,7 +305,7 @@ export abstract class LiveObject< // For LiveMapUpdate, also create non-bubbling events for each updated key if (update._type === 'LiveMapUpdate') { - const updatedKeys = Object.keys((update as LiveMapUpdate).update); + const updatedKeys = Object.keys(update.update); for (const key of updatedKeys) { for (const basePath of paths) { diff --git a/src/plugins/objects/objectmessage.ts b/src/plugins/objects/objectmessage.ts index 26cdd694e1..f111c742ef 100644 --- a/src/plugins/objects/objectmessage.ts +++ b/src/plugins/objects/objectmessage.ts @@ -33,8 +33,6 @@ export enum ObjectsMapSemantics { LWW = 0, } -export type PrimitiveObjectValue = string | number | boolean | Buffer | ArrayBuffer | JsonArray | JsonObject; - /** * An ObjectData represents a value in an object on a channel decoded from {@link WireObjectData}. * @spec OD1 @@ -43,7 +41,7 @@ export interface ObjectData { /** A reference to another object, used to support composable object structures. */ objectId?: string; // OD2a /** A decoded leaf value from {@link WireObjectData}. */ - value?: PrimitiveObjectValue; + value?: API.Primitive; } /** diff --git a/src/plugins/objects/objectspool.ts b/src/plugins/objects/objectspool.ts index 854647d121..4c2fe021b2 100644 --- a/src/plugins/objects/objectspool.ts +++ b/src/plugins/objects/objectspool.ts @@ -1,5 +1,4 @@ import type BaseClient from 'common/lib/client/baseclient'; -import type * as API from '../../../ably'; import { ROOT_OBJECT_ID } from './constants'; import { DEFAULTS } from './defaults'; import { LiveCounter } from './livecounter'; @@ -31,8 +30,8 @@ export class ObjectsPool { return this._pool.get(objectId); } - getRoot(): LiveMap { - return this._pool.get(ROOT_OBJECT_ID) as LiveMap; + getRoot(): LiveMap { + return this._pool.get(ROOT_OBJECT_ID) as LiveMap; } /** diff --git a/src/plugins/objects/pathobject.ts b/src/plugins/objects/pathobject.ts index bc5a48c944..9f7fc2b831 100644 --- a/src/plugins/objects/pathobject.ts +++ b/src/plugins/objects/pathobject.ts @@ -1,5 +1,4 @@ import type BaseClient from 'common/lib/client/baseclient'; -import type * as API from '../../../ably'; import type { AnyPathObject, BatchContext, @@ -12,7 +11,7 @@ import type { PathObjectSubscriptionEvent, PathObjectSubscriptionOptions, Primitive, - SubscribeResponse, + Subscription, Value, } from '../../../ably'; import { DefaultInstance } from './instance'; @@ -32,7 +31,7 @@ export class DefaultPathObject implements AnyPathObject { constructor( private _realtimeObject: RealtimeObject, - private _root: LiveMap, + private _root: LiveMap, path: string[], parent?: DefaultPathObject, ) { @@ -352,7 +351,7 @@ export class DefaultPathObject implements AnyPathObject { subscribe( listener: EventCallback, options?: PathObjectSubscriptionOptions, - ): SubscribeResponse { + ): Subscription { return this._realtimeObject.getPathObjectSubscriptionRegister().subscribe(this._path, listener, options ?? {}); } @@ -383,8 +382,7 @@ export class DefaultPathObject implements AnyPathObject { } private _resolvePath(path: string[]): Value { - // TODO: remove type assertion when internal LiveMap is updated to support new path based type system - let current: Value = this._root as unknown as API.LiveMap; + let current: Value = this._root; for (let i = 0; i < path.length; i++) { const segment = path[i]; diff --git a/src/plugins/objects/pathobjectsubscriptionregister.ts b/src/plugins/objects/pathobjectsubscriptionregister.ts index 588cdd440d..00e913b4d7 100644 --- a/src/plugins/objects/pathobjectsubscriptionregister.ts +++ b/src/plugins/objects/pathobjectsubscriptionregister.ts @@ -3,7 +3,7 @@ import type { EventCallback, PathObjectSubscriptionEvent, PathObjectSubscriptionOptions, - SubscribeResponse, + Subscription, } from '../../../ably'; import { ObjectMessage } from './objectmessage'; import { DefaultPathObject } from './pathobject'; @@ -60,7 +60,7 @@ export class PathObjectSubscriptionRegister { path: string[], listener: EventCallback, options: PathObjectSubscriptionOptions, - ): SubscribeResponse { + ): Subscription { if (options != null && typeof options !== 'object') { throw new this._client.ErrorInfo('Subscription options must be an object', 40000, 400); } diff --git a/src/plugins/objects/realtimeobject.ts b/src/plugins/objects/realtimeobject.ts index 817e497cbb..3e4fa5b175 100644 --- a/src/plugins/objects/realtimeobject.ts +++ b/src/plugins/objects/realtimeobject.ts @@ -78,21 +78,8 @@ export class RealtimeObject { * When called without a type variable, we return a default root type which is based on globally defined interface for Objects feature. * A user can provide an explicit type for the this method to explicitly set the type structure on this particular channel. * This is useful when working with multiple channels with different underlying data structure. - * @spec RTO1 */ - async get(): Promise> { - this.throwIfInvalidAccessApiConfiguration(); // RTO1a, RTO1b - - // if we're not synced yet, wait for sync sequence to finish before returning root - if (this._state !== ObjectsState.synced) { - await this._eventEmitterInternal.once(ObjectsEvent.synced); // RTO1c - } - - return this._objectsPool.getRoot(); // RTO1d - } - - // TODO: replace .get call with this one when we have full path object API support. - async getPathObject>(): Promise>> { + async get = API.AblyDefaultObject>(): Promise>> { this.throwIfInvalidAccessApiConfiguration(); // RTO1a, RTO1b // if we're not synced yet, wait for sync sequence to finish before returning root diff --git a/test/common/modules/private_api_recorder.js b/test/common/modules/private_api_recorder.js index 08af19c596..338fe3ffb1 100644 --- a/test/common/modules/private_api_recorder.js +++ b/test/common/modules/private_api_recorder.js @@ -15,7 +15,6 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths) 'call.Defaults.normaliseOptions', 'call.EventEmitter.emit', 'call.EventEmitter.listeners', - 'call.LiveObject.getObjectId', 'call.LiveObject.isTombstoned', 'call.LiveObject.tombstonedAt', 'call.Message.decode', diff --git a/test/package/browser/template/src/index-objects.ts b/test/package/browser/template/src/index-objects.ts index 6cdc7755ad..c40e02331b 100644 --- a/test/package/browser/template/src/index-objects.ts +++ b/test/package/browser/template/src/index-objects.ts @@ -1,5 +1,5 @@ import * as Ably from 'ably'; -import { LiveCounterDeprecated, LiveMapDeprecated } from 'ably'; +import { LiveCounter, LiveMap } from 'ably'; import Objects from 'ably/objects'; import { createSandboxAblyAPIKey } from './sandbox'; @@ -14,13 +14,13 @@ type MyCustomObject = { stringKey: string; booleanKey: boolean; couldBeUndefined?: string; - mapKey: LiveMapDeprecated<{ + mapKey: LiveMap<{ foo: 'bar'; - nestedMap?: LiveMapDeprecated<{ + nestedMap?: LiveMap<{ baz: 'qux'; }>; }>; - counterKey: LiveCounterDeprecated; + counterKey: LiveCounter; }; declare global { @@ -39,57 +39,40 @@ globalThis.testAblyPackage = async function () { const realtime = new Ably.Realtime({ key, endpoint: 'nonprod:sandbox', plugins: { Objects } }); const channel = realtime.channels.get('channel', { modes: ['OBJECT_SUBSCRIBE', 'OBJECT_PUBLISH'] }); - // check Objects can be accessed await channel.attach(); - // expect entrypoint to be a LiveMap instance with Objects types defined via the global AblyObjectsTypes interface - // also checks that we can refer to the Objects types exported from 'ably' by referencing a LiveMap interface - const myObject: LiveMapDeprecated = await channel.object.get(); + // check Objects can be accessed. + // expect entrypoint to be a PathObject for a LiveMap instance with Object type defined via the global AblyObjectsTypes interface. + // also checks that we can refer to the Objects types exported from 'ably'. + const myObject: Ably.PathObject> = await channel.object.get(); // check entrypoint has expected LiveMap TypeScript type methods - const size: number = myObject.size(); + const size: number | undefined = myObject.size(); // check custom user provided typings via AblyObjectsTypes are working: - // any LiveMap.get() call can return undefined, as the LiveMap itself can be tombstoned (has empty state), - // or referenced object is tombstoned. - // keys on the entrypoint: - const aNumber: number | undefined = myObject.get('numberKey'); - const aString: string | undefined = myObject.get('stringKey'); - const aBoolean: boolean | undefined = myObject.get('booleanKey'); - const userProvidedUndefined: string | undefined = myObject.get('couldBeUndefined'); + const aNumber: number | undefined = myObject.get('numberKey').value(); + const aString: string | undefined = myObject.get('stringKey').value(); + const aBoolean: boolean | undefined = myObject.get('booleanKey').value(); + const userProvidedUndefined: string | undefined = myObject.get('couldBeUndefined').value(); // objects on the entrypoint: - const counter: LiveCounterDeprecated | undefined = myObject.get('counterKey'); - const map: AblyObjectsTypes['object']['mapKey'] | undefined = myObject.get('mapKey'); + const counter: Ably.LiveCounterPathObject = myObject.get('counterKey'); + const map: Ably.LiveMapPathObject = myObject.get('mapKey'); // check string literal types works // need to use nullish coalescing as we didn't actually create any data on the entrypoint object, // so the next calls would fail. we only need to check that TypeScript types work - const foo: 'bar' = map?.get('foo')!; - const baz: 'qux' = map?.get('nestedMap')?.get('baz')!; - - // check LiveMap subscription callback has correct TypeScript types - const { unsubscribe } = myObject.subscribe(({ update }) => { - // check update object infers keys from map type - const typedKeyOnMap = update.stringKey; - switch (typedKeyOnMap) { - case 'removed': - case 'updated': - case undefined: - break; - default: - // check all possible types are exhausted - const shouldExhaustAllTypes: never = typedKeyOnMap; - } - }); - unsubscribe(); - + const foo: 'bar' = map?.get('foo')?.value()!; + const baz: 'qux' = map?.get('nestedMap')?.get('baz')?.value()!; // check LiveCounter type also behaves as expected - // same deal with nullish coalescing const value: number = counter?.value()!; - const counterSubscribeResponse = counter?.subscribe(({ update }) => { - const shouldBeANumber: number = update.amount; + + // check subscription callback has correct TypeScript types + const { unsubscribe } = myObject.subscribe(({ object, message }) => { + const typedObject: Ably.AnyPathObject = object; + const typedMessage: Ably.ObjectMessage | undefined = message; }); - counterSubscribeResponse?.unsubscribe(); + unsubscribe(); // check can provide custom types for the object.get() method, ignoring global AblyObjectsTypes interface - const explicitObjectType: LiveMapDeprecated = await channel.object.get(); - const someOtherKey: string | undefined = explicitObjectType.get('someOtherKey'); + const explicitObjectType: Ably.PathObject> = + await channel.object.get(); + const someOtherKey: string | undefined = explicitObjectType.get('someOtherKey').value(); }; diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js index ef2c7a4931..544b828402 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/objects.test.js @@ -172,7 +172,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const expectedKeys = ObjectsHelper.fixtureRootKeys(); await channel.attach(); - const entryPathObject = await channel.object.getPathObject(); + const entryPathObject = await channel.object.get(); const entryInstance = entryPathObject.instance(); await Promise.all( @@ -286,21 +286,6 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expectInstanceOf(channel.object, 'RealtimeObject'); }); - /** @nospec */ - it('RealtimeObject.get() returns LiveMap instance', async function () { - const helper = this.test.helper; - const client = RealtimeWithObjects(helper); - - await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get('channel', channelOptionsWithObjects()); - - await channel.attach(); - const root = await channel.object.get(); - - expectInstanceOf(root, 'LiveMap', 'root object should be of LiveMap type'); - }, client); - }); - /** @nospec */ it('RealtimeObject.get() returns LiveObject with id "root"', async function () { const helper = this.test.helper; @@ -310,10 +295,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const channel = client.channels.get('channel', channelOptionsWithObjects()); await channel.attach(); - const root = await channel.object.get(); + const entryPathObject = await channel.object.get(); - helper.recordPrivateApi('call.LiveObject.getObjectId'); - expect(root.getObjectId()).to.equal('root', 'root object should have an object id "root"'); + expect(entryPathObject.instance().id()).to.equal('root', 'root object should have an object id "root"'); }, client); }); @@ -326,9 +310,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const channel = client.channels.get('channel', channelOptionsWithObjects()); await channel.attach(); - const root = await channel.object.get(); + const entryPathObject = await channel.object.get(); - expect(root.size()).to.equal(0, 'Check root has no keys'); + expect(entryPathObject.size()).to.equal(0, 'Check root has no keys'); }, client); }); @@ -406,10 +390,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); let getResolved = false; - let root; + let entryInstance; channel.object.get().then((value) => { getResolved = true; - root = value; + entryInstance = value; }); // wait for next tick to check that RealtimeObject.get() promise handler didn't proc @@ -438,30 +422,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await new Promise((res) => nextTick(res)); expect(getResolved, 'Check RealtimeObject.get() is resolved when OBJECT_SYNC sequence has ended').to.be.true; - expect(root.get('key')).to.equal(1, 'Check new root after OBJECT_SYNC sequence has expected key'); + expect(entryInstance.get('key').value()).to.equal( + 1, + 'Check new root after OBJECT_SYNC sequence has expected key', + ); }, client); }); - function checkKeyDataOnMap({ helper, key, keyData, mapObj, msg }) { - if (keyData.data.bytes != null) { - helper.recordPrivateApi('call.BufferUtils.base64Decode'); - helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); - expect(BufferUtils.areBuffersEqual(mapObj.get(key), BufferUtils.base64Decode(keyData.data.bytes)), msg).to.be - .true; - } else if (keyData.data.json != null) { - const expectedObject = JSON.parse(keyData.data.json); - expect(mapObj.get(key)).to.deep.equal(expectedObject, msg); - } else { - const expectedValue = keyData.data.string ?? keyData.data.number ?? keyData.data.boolean; - expect(mapObj.get(key)).to.equal(expectedValue, msg); - } - } - - function checkKeyDataOnPathObject({ helper, key, keyData, mapObj, pathObject, msg }) { - // should check that both mapObj and pathObject return the same value for the key - // and it matches the expected value from keyData - const compareMsg = `Check PathObject and LiveMap have the same value for "${keyData.key}" key`; - + function checkKeyDataOnPathObject({ helper, key, keyData, pathObject, msg }) { if (keyData.data.bytes != null) { helper.recordPrivateApi('call.BufferUtils.base64Decode'); helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); @@ -469,18 +437,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function BufferUtils.areBuffersEqual(pathObject.get(key).value(), BufferUtils.base64Decode(keyData.data.bytes)), msg, ).to.be.true; - - helper.recordPrivateApi('call.BufferUtils.base64Decode'); - helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); - expect(BufferUtils.areBuffersEqual(pathObject.get(key).value(), mapObj.get(key)), compareMsg).to.be.true; } else if (keyData.data.json != null) { const expectedObject = JSON.parse(keyData.data.json); expect(pathObject.get(key).value()).to.deep.equal(expectedObject, msg); - expect(pathObject.get(key).value()).to.deep.equal(mapObj.get(key), compareMsg); } else { const expectedValue = keyData.data.string ?? keyData.data.number ?? keyData.data.boolean; expect(pathObject.get(key).value()).to.equal(expectedValue, msg); - expect(pathObject.get(key).value()).to.equal(mapObj.get(key), compareMsg); } } @@ -546,35 +508,42 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'OBJECT_SYNC sequence builds object tree on channel attachment', action: async (ctx) => { - const { client } = ctx; + const { client, helper } = ctx; await waitFixtureChannelIsReady(client); const channel = client.channels.get(objectsFixturesChannel, channelOptionsWithObjects()); await channel.attach(); - const root = await channel.object.get(); + const entryPathObject = await channel.object.get(); + const entryInstance = entryPathObject.instance(); const counterKeys = ['emptyCounter', 'initialValueCounter', 'referencedCounter']; const mapKeys = ['emptyMap', 'referencedMap', 'valuesMap']; const rootKeysCount = counterKeys.length + mapKeys.length; - expect(root, 'Check RealtimeObject.get() is resolved when OBJECT_SYNC sequence ends').to.exist; - expect(root.size()).to.equal(rootKeysCount, 'Check root has correct number of keys'); + expect(entryInstance, 'Check RealtimeObject.get() is resolved when OBJECT_SYNC sequence ends').to.exist; + expect(entryInstance.size()).to.equal(rootKeysCount, 'Check root has correct number of keys'); counterKeys.forEach((key) => { - const counter = root.get(key); + const counter = entryInstance.get(key); expect(counter, `Check counter at key="${key}" in root exists`).to.exist; - expectInstanceOf(counter, 'LiveCounter', `Check counter at key="${key}" in root is of type LiveCounter`); + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf( + counter._value, + 'LiveCounter', + `Check counter at key="${key}" in root is of type LiveCounter`, + ); }); mapKeys.forEach((key) => { - const map = root.get(key); + const map = entryInstance.get(key); expect(map, `Check map at key="${key}" in root exists`).to.exist; - expectInstanceOf(map, 'LiveMap', `Check map at key="${key}" in root is of type LiveMap`); + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf(map._value, 'LiveMap', `Check map at key="${key}" in root is of type LiveMap`); }); - const valuesMap = root.get('valuesMap'); + const valuesMap = entryInstance.get('valuesMap'); const valueMapKeys = [ 'stringKey', 'emptyStringKey', @@ -592,8 +561,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ]; expect(valuesMap.size()).to.equal(valueMapKeys.length, 'Check nested map has correct number of keys'); valueMapKeys.forEach((key) => { - const value = valuesMap.get(key); - expect(value, `Check value at key="${key}" in nested map exists`).to.exist; + expect(valuesMap.get(key), `Check value at key="${key}" in nested map exists`).to.exist; }); }, }, @@ -642,22 +610,24 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const channel2 = client2.channels.get(channelName, channelOptionsWithObjects()); await channel2.attach(); - const root2 = await channel2.object.get(); + const pathObject2 = await channel2.object.get(); + const entryInstance2 = pathObject2.instance(); - expect(root2.get('counter'), 'Check counter exists').to.exist; - expect(root2.get('counter').value()).to.equal(11, 'Check counter has correct value'); + expect(entryInstance2.get('counter'), 'Check counter exists').to.exist; + expect(entryInstance2.get('counter').value()).to.equal(11, 'Check counter has correct value'); - expect(root2.get('map'), 'Check map exists').to.exist; - expect(root2.get('map').size()).to.equal(2, 'Check map has correct number of keys'); - expect(root2.get('map').get('shouldStay')).to.equal( + expect(entryInstance2.get('map'), 'Check map exists').to.exist; + expect(entryInstance2.get('map').size()).to.equal(2, 'Check map has correct number of keys'); + expect(entryInstance2.get('map').get('shouldStay').value()).to.equal( 'foo', 'Check map has correct value for "shouldStay" key', ); - expect(root2.get('map').get('anotherKey')).to.equal( + expect(entryInstance2.get('map').get('anotherKey').value()).to.equal( 'baz', 'Check map has correct value for "anotherKey" key', ); - expect(root2.get('map').get('shouldDelete'), 'Check map does not have "shouldDelete" key').to.not.exist; + expect(entryInstance2.get('map').get('shouldDelete'), 'Check map does not have "shouldDelete" key').to.not + .exist; }, client2); }, }, @@ -665,19 +635,19 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'OBJECT_SYNC sequence does not change references to existing objects', action: async (ctx) => { - const { root, helper, channel, entryInstance } = ctx; + const { helper, channel, entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(entryInstance, 'counter'), waitForMapKeyUpdate(entryInstance, 'map'), ]); await Promise.all([ - root.set('map', LiveMap.create()), - root.set('counter', LiveCounter.create()), + entryInstance.set('map', LiveMap.create()), + entryInstance.set('counter', LiveCounter.create()), objectsCreatedPromise, ]); - const map = root.get('map'); - const counter = root.get('counter'); + const map = entryInstance.get('map'); + const counter = entryInstance.get('counter'); await channel.detach(); @@ -686,13 +656,23 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await channel.attach(); await objectSyncPromise; - const newRootRef = await channel.object.get(); - const newMapRef = newRootRef.get('map'); - const newCounterRef = newRootRef.get('counter'); + const newEntryPathObject = await channel.object.get(); + const newEntryInstance = newEntryPathObject.instance(); + const newMapRef = newEntryInstance.get('map'); + const newCounterRef = newEntryInstance.get('counter'); - expect(newRootRef).to.equal(root, 'Check root reference is the same after OBJECT_SYNC sequence'); - expect(newMapRef).to.equal(map, 'Check map reference is the same after OBJECT_SYNC sequence'); - expect(newCounterRef).to.equal(counter, 'Check counter reference is the same after OBJECT_SYNC sequence'); + helper.recordPrivateApi('read.DefaultInstance._value'); + expect(newEntryInstance._value).to.equal( + entryInstance._value, + 'Check root reference is the same after OBJECT_SYNC sequence', + ); + helper.recordPrivateApi('read.DefaultInstance._value'); + expect(newMapRef._value).to.equal(map._value, 'Check map reference is the same after OBJECT_SYNC sequence'); + helper.recordPrivateApi('read.DefaultInstance._value'); + expect(newCounterRef._value).to.equal( + counter._value, + 'Check counter reference is the same after OBJECT_SYNC sequence', + ); }, }, @@ -707,7 +687,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const channel = client.channels.get(objectsFixturesChannel, channelOptionsWithObjects()); await channel.attach(); - const root = await channel.object.get(); + const entryPathObject = await channel.object.get(); const counters = [ { key: 'emptyCounter', value: 0 }, @@ -716,7 +696,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ]; counters.forEach((x) => { - const counter = root.get(x.key); + const counter = entryPathObject.get(x.key); expect(counter.value()).to.equal(x.value, `Check counter at key="${x.key}" in root has correct value`); }); }, @@ -733,27 +713,33 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const channel = client.channels.get(objectsFixturesChannel, channelOptionsWithObjects()); await channel.attach(); - const root = await channel.object.get(); + const entryPathObject = await channel.object.get(); - const emptyMap = root.get('emptyMap'); + const emptyMap = entryPathObject.get('emptyMap'); expect(emptyMap.size()).to.equal(0, 'Check empty map in root has no keys'); - const referencedMap = root.get('referencedMap'); + const referencedMap = entryPathObject.get('referencedMap'); expect(referencedMap.size()).to.equal(1, 'Check referenced map in root has correct number of keys'); const counterFromReferencedMap = referencedMap.get('counterKey'); expect(counterFromReferencedMap.value()).to.equal(20, 'Check nested counter has correct value'); - const valuesMap = root.get('valuesMap'); + const valuesMap = entryPathObject.get('valuesMap'); expect(valuesMap.size()).to.equal(13, 'Check values map in root has correct number of keys'); - expect(valuesMap.get('stringKey')).to.equal('stringValue', 'Check values map has correct string value key'); - expect(valuesMap.get('emptyStringKey')).to.equal('', 'Check values map has correct empty string value key'); + expect(valuesMap.get('stringKey').value()).to.equal( + 'stringValue', + 'Check values map has correct string value key', + ); + expect(valuesMap.get('emptyStringKey').value()).to.equal( + '', + 'Check values map has correct empty string value key', + ); helper.recordPrivateApi('call.BufferUtils.base64Decode'); helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); expect( BufferUtils.areBuffersEqual( - valuesMap.get('bytesKey'), + valuesMap.get('bytesKey').value(), BufferUtils.base64Decode('eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9'), ), 'Check values map has correct bytes value key', @@ -761,26 +747,26 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function helper.recordPrivateApi('call.BufferUtils.base64Decode'); helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); expect( - BufferUtils.areBuffersEqual(valuesMap.get('emptyBytesKey'), BufferUtils.base64Decode('')), + BufferUtils.areBuffersEqual(valuesMap.get('emptyBytesKey').value(), BufferUtils.base64Decode('')), 'Check values map has correct empty bytes value key', ).to.be.true; - expect(valuesMap.get('maxSafeIntegerKey')).to.equal( + expect(valuesMap.get('maxSafeIntegerKey').value()).to.equal( Number.MAX_SAFE_INTEGER, 'Check values map has correct maxSafeIntegerKey value', ); - expect(valuesMap.get('negativeMaxSafeIntegerKey')).to.equal( + expect(valuesMap.get('negativeMaxSafeIntegerKey').value()).to.equal( -Number.MAX_SAFE_INTEGER, 'Check values map has correct negativeMaxSafeIntegerKey value', ); - expect(valuesMap.get('numberKey')).to.equal(1, 'Check values map has correct number value key'); - expect(valuesMap.get('zeroKey')).to.equal(0, 'Check values map has correct zero number value key'); - expect(valuesMap.get('trueKey')).to.equal(true, `Check values map has correct 'true' value key`); - expect(valuesMap.get('falseKey')).to.equal(false, `Check values map has correct 'false' value key`); - expect(valuesMap.get('objectKey')).to.deep.equal( + expect(valuesMap.get('numberKey').value()).to.equal(1, 'Check values map has correct number value key'); + expect(valuesMap.get('zeroKey').value()).to.equal(0, 'Check values map has correct zero number value key'); + expect(valuesMap.get('trueKey').value()).to.equal(true, `Check values map has correct 'true' value key`); + expect(valuesMap.get('falseKey').value()).to.equal(false, `Check values map has correct 'false' value key`); + expect(valuesMap.get('objectKey').value()).to.deep.equal( { foo: 'bar' }, `Check values map has correct objectKey value`, ); - expect(valuesMap.get('arrayKey')).to.deep.equal( + expect(valuesMap.get('arrayKey').value()).to.deep.equal( ['foo', 'bar', 'baz'], `Check values map has correct arrayKey value`, ); @@ -790,47 +776,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, }, - { - allTransportsAndProtocols: true, - description: 'LiveMap can reference the same object in their keys', - action: async (ctx) => { - const { client } = ctx; - - await waitFixtureChannelIsReady(client); - - const channel = client.channels.get(objectsFixturesChannel, channelOptionsWithObjects()); - - await channel.attach(); - const root = await channel.object.get(); - - const referencedCounter = root.get('referencedCounter'); - const referencedMap = root.get('referencedMap'); - const valuesMap = root.get('valuesMap'); - - const counterFromReferencedMap = referencedMap.get('counterKey'); - expect(counterFromReferencedMap, 'Check nested counter exists at a key in a map').to.exist; - expectInstanceOf(counterFromReferencedMap, 'LiveCounter', 'Check nested counter is of type LiveCounter'); - expect(counterFromReferencedMap).to.equal( - referencedCounter, - 'Check nested counter is the same object instance as counter on the root', - ); - expect(counterFromReferencedMap.value()).to.equal(20, 'Check nested counter has correct value'); - - const mapFromValuesMap = valuesMap.get('mapKey'); - expect(mapFromValuesMap, 'Check nested map exists at a key in a map').to.exist; - expectInstanceOf(mapFromValuesMap, 'LiveMap', 'Check nested map is of type LiveMap'); - expect(mapFromValuesMap.size()).to.equal(1, 'Check nested map has correct number of keys'); - expect(mapFromValuesMap).to.equal( - referencedMap, - 'Check nested map is the same object instance as map on the root', - ); - }, - }, - { description: 'OBJECT_SYNC sequence with "tombstone=true" for an object creates tombstoned object', action: async (ctx) => { - const { root, objectsHelper, channel } = ctx; + const { entryInstance, objectsHelper, channel } = ctx; const mapId = objectsHelper.fakeMapObjectId(); const counterId = objectsHelper.fakeCounterObjectId(); @@ -868,15 +817,15 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); expect( - root.get('map'), + entryInstance.get('map'), 'Check map does not exist on root after OBJECT_SYNC with "tombstone=true" for a map object', ).to.not.exist; expect( - root.get('counter'), + entryInstance.get('counter'), 'Check counter does not exist on root after OBJECT_SYNC with "tombstone=true" for a counter object', ).to.not.exist; // control check that OBJECT_SYNC was applied at all - expect(root.get('foo'), 'Check property exists on root after OBJECT_SYNC').to.exist; + expect(entryInstance.get('foo'), 'Check property exists on root after OBJECT_SYNC').to.exist; }, }, @@ -884,7 +833,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'OBJECT_SYNC sequence with "tombstone=true" for an object deletes existing object', action: async (ctx) => { - const { root, objectsHelper, channelName, channel, entryInstance } = ctx; + const { objectsHelper, channelName, channel, entryInstance } = ctx; const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); const { objectId: counterId } = await objectsHelper.createAndSetOnMap(channelName, { @@ -895,7 +844,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await counterCreatedPromise; expect( - root.get('counter'), + entryInstance.get('counter'), 'Check counter exists on root before OBJECT_SYNC sequence with "tombstone=true"', ).to.exist; @@ -924,11 +873,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); expect( - root.get('counter'), + entryInstance.get('counter'), 'Check counter does not exist on root after OBJECT_SYNC with "tombstone=true" for an existing counter object', ).to.not.exist; // control check that OBJECT_SYNC was applied at all - expect(root.get('foo'), 'Check property exists on root after OBJECT_SYNC').to.exist; + expect(entryInstance.get('foo'), 'Check property exists on root after OBJECT_SYNC').to.exist; }, }, @@ -937,7 +886,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'OBJECT_SYNC sequence with "tombstone=true" for an object triggers subscription callback for existing object', action: async (ctx) => { - const { root, objectsHelper, channelName, channel, entryInstance } = ctx; + const { objectsHelper, channelName, channel, entryInstance } = ctx; const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); const { objectId: counterId } = await objectsHelper.createAndSetOnMap(channelName, { @@ -1079,7 +1028,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'OBJECT_SYNC sequence with "tombstone=true" for a map entry sets "tombstoneAt" from "serialTimestamp"', action: async (ctx) => { - const { helper, root, objectsHelper, channel } = ctx; + const { helper, entryInstance, objectsHelper, channel } = ctx; const serialTimestamp = 1234567890; await objectsHelper.processObjectStateMessageOnChannel({ @@ -1101,8 +1050,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ], }); + helper.recordPrivateApi('read.DefaultInstance._value'); helper.recordPrivateApi('read.LiveMap._dataRef.data'); - const mapEntry = root._dataRef.data.get('foo'); + const mapEntry = entryInstance._value._dataRef.data.get('foo'); expect( mapEntry, 'Check map entry is added to root internal data after OBJECT_SYNC sequence with "tombstone=true" for a map entry', @@ -1118,7 +1068,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'OBJECT_SYNC sequence with "tombstone=true" for a map entry sets "tombstoneAt" using local clock if missing "serialTimestamp"', action: async (ctx) => { - const { helper, root, objectsHelper, channel } = ctx; + const { helper, entryInstance, objectsHelper, channel } = ctx; const tsBeforeMsg = Date.now(); await objectsHelper.processObjectStateMessageOnChannel({ @@ -1141,8 +1091,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); const tsAfterMsg = Date.now(); + helper.recordPrivateApi('read.DefaultInstance._value'); helper.recordPrivateApi('read.LiveMap._dataRef.data'); - const mapEntry = root._dataRef.data.get('foo'); + const mapEntry = entryInstance._value._dataRef.data.get('foo'); expect( mapEntry, 'Check map entry is added to root internal data after OBJECT_SYNC sequence with "tombstone=true" for a map entry', @@ -1160,7 +1111,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'can apply MAP_CREATE with primitives object operation messages', action: async (ctx) => { - const { root, objectsHelper, channelName, helper, entryInstance } = ctx; + const { objectsHelper, channelName, helper, entryInstance } = ctx; // Objects public API allows us to check value of objects we've created based on MAP_CREATE ops // if we assign those objects to another map (root for example), as there is no way to access those objects from the internal pool directly. @@ -1169,8 +1120,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // check no maps exist on root primitiveMapsFixtures.forEach((fixture) => { const key = fixture.name; - expect(root.get(key), `Check "${key}" key doesn't exist on root before applying MAP_CREATE ops`).to.not - .exist; + expect(entryInstance.get(key), `Check "${key}" key doesn't exist on root before applying MAP_CREATE ops`) + .to.not.exist; }); const mapsCreatedPromise = Promise.all( @@ -1191,24 +1142,25 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // check created maps primitiveMapsFixtures.forEach((fixture) => { const mapKey = fixture.name; - const mapObj = root.get(mapKey); + const map = entryInstance.get(mapKey); // check all maps exist on root - expect(mapObj, `Check map at "${mapKey}" key in root exists`).to.exist; - expectInstanceOf(mapObj, 'LiveMap', `Check map at "${mapKey}" key in root is of type LiveMap`); + expect(map, `Check map at "${mapKey}" key in root exists`).to.exist; + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf(map._value, 'LiveMap', `Check map at "${mapKey}" key in root is of type LiveMap`); // check primitive maps have correct values - expect(mapObj.size()).to.equal( + expect(map.size()).to.equal( Object.keys(fixture.entries ?? {}).length, `Check map "${mapKey}" has correct number of keys`, ); Object.entries(fixture.entries ?? {}).forEach(([key, keyData]) => { - checkKeyDataOnMap({ + checkKeyDataOnInstance({ helper, key, keyData, - mapObj, + instance: map, msg: `Check map "${mapKey}" has correct value for "${key}" key`, }); }); @@ -1220,7 +1172,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'can apply MAP_CREATE with object ids object operation messages', action: async (ctx) => { - const { root, objectsHelper, channelName, entryInstance } = ctx; + const { objectsHelper, channelName, entryInstance, helper } = ctx; const withReferencesMapKey = 'withReferencesMap'; // Objects public API allows us to check value of objects we've created based on MAP_CREATE ops @@ -1229,7 +1181,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // check map does not exist on root expect( - root.get(withReferencesMapKey), + entryInstance.get(withReferencesMapKey), `Check "${withReferencesMapKey}" key doesn't exist on root before applying MAP_CREATE ops`, ).to.not.exist; @@ -1256,10 +1208,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await mapCreatedPromise; // check map with references exist on root - const withReferencesMap = root.get(withReferencesMapKey); + const withReferencesMap = entryInstance.get(withReferencesMapKey); expect(withReferencesMap, `Check map at "${withReferencesMapKey}" key in root exists`).to.exist; + helper.recordPrivateApi('read.DefaultInstance._value'); expectInstanceOf( - withReferencesMap, + withReferencesMap._value, 'LiveMap', `Check map at "${withReferencesMapKey}" key in root is of type LiveMap`, ); @@ -1274,18 +1227,20 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const referencedMap = withReferencesMap.get('mapReference'); expect(referencedCounter, `Check counter at "counterReference" exists`).to.exist; + helper.recordPrivateApi('read.DefaultInstance._value'); expectInstanceOf( - referencedCounter, + referencedCounter._value, 'LiveCounter', `Check counter at "counterReference" key is of type LiveCounter`, ); expect(referencedCounter.value()).to.equal(1, 'Check counter at "counterReference" key has correct value'); expect(referencedMap, `Check map at "mapReference" key exists`).to.exist; - expectInstanceOf(referencedMap, 'LiveMap', `Check map at "mapReference" key is of type LiveMap`); + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf(referencedMap._value, 'LiveMap', `Check map at "mapReference" key is of type LiveMap`); expect(referencedMap.size()).to.equal(1, 'Check map at "mapReference" key has correct number of keys'); - expect(referencedMap.get('stringKey')).to.equal( + expect(referencedMap.get('stringKey').value()).to.equal( 'stringValue', 'Check map at "mapReference" key has correct "stringKey" value', ); @@ -1296,7 +1251,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'MAP_CREATE object operation messages are applied based on the site timeserials vector of the object', action: async (ctx) => { - const { root, objectsHelper, channel } = ctx; + const { entryInstance, objectsHelper, channel } = ctx; // need to use multiple maps as MAP_CREATE op can only be applied once to a map object const mapIds = [ @@ -1360,12 +1315,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const expectedMapValue = expectedMapValues[i]; const expectedKeysCount = Object.keys(expectedMapValue).length; - expect(root.get(mapId).size()).to.equal( + expect(entryInstance.get(mapId).size()).to.equal( expectedKeysCount, `Check map #${i + 1} has expected number of keys after MAP_CREATE ops`, ); Object.entries(expectedMapValue).forEach(([key, value]) => { - expect(root.get(mapId).get(key)).to.equal( + expect(entryInstance.get(mapId).get(key).value()).to.equal( value, `Check map #${i + 1} has expected value for "${key}" key after MAP_CREATE ops`, ); @@ -1378,12 +1333,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'can apply MAP_SET with primitives object operation messages', action: async (ctx) => { - const { root, objectsHelper, channelName, helper, entryInstance } = ctx; + const { objectsHelper, channelName, helper, entryInstance } = ctx; // check root is empty before ops primitiveKeyData.forEach((keyData) => { expect( - root.get(keyData.key), + entryInstance.get(keyData.key), `Check "${keyData.key}" key doesn't exist on root before applying MAP_SET ops`, ).to.not.exist; }); @@ -1408,11 +1363,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // check everything is applied correctly primitiveKeyData.forEach((keyData) => { - checkKeyDataOnMap({ + checkKeyDataOnInstance({ helper, key: keyData.key, keyData, - mapObj: root, + instance: entryInstance, msg: `Check root has correct value for "${keyData.key}" key after MAP_SET op`, }); }); @@ -1423,15 +1378,17 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'can apply MAP_SET with object ids object operation messages', action: async (ctx) => { - const { root, objectsHelper, channelName, entryInstance } = ctx; + const { objectsHelper, channelName, entryInstance, helper } = ctx; // check no object ids are set on root expect( - root.get('keyToCounter'), + entryInstance.get('keyToCounter'), `Check "keyToCounter" key doesn't exist on root before applying MAP_SET ops`, ).to.not.exist; - expect(root.get('keyToMap'), `Check "keyToMap" key doesn't exist on root before applying MAP_SET ops`).to - .not.exist; + expect( + entryInstance.get('keyToMap'), + `Check "keyToMap" key doesn't exist on root before applying MAP_SET ops`, + ).to.not.exist; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(entryInstance, 'keyToCounter'), @@ -1456,21 +1413,23 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await objectsCreatedPromise; // check root has refs to new objects and they are not zero-value - const counter = root.get('keyToCounter'); - const map = root.get('keyToMap'); + const counter = entryInstance.get('keyToCounter'); + const map = entryInstance.get('keyToMap'); expect(counter, 'Check counter at "keyToCounter" key in root exists').to.exist; + helper.recordPrivateApi('read.DefaultInstance._value'); expectInstanceOf( - counter, + counter._value, 'LiveCounter', 'Check counter at "keyToCounter" key in root is of type LiveCounter', ); expect(counter.value()).to.equal(1, 'Check counter at "keyToCounter" key in root has correct value'); expect(map, 'Check map at "keyToMap" key in root exists').to.exist; - expectInstanceOf(map, 'LiveMap', 'Check map at "keyToMap" key in root is of type LiveMap'); + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf(map._value, 'LiveMap', 'Check map at "keyToMap" key in root is of type LiveMap'); expect(map.size()).to.equal(1, 'Check map at "keyToMap" key in root has correct number of keys'); - expect(map.get('stringKey')).to.equal( + expect(map.get('stringKey').value()).to.equal( 'stringValue', 'Check map at "keyToMap" key in root has correct "stringKey" value', ); @@ -1481,7 +1440,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'MAP_SET object operation messages are applied based on the site timeserials vector of the object', action: async (ctx) => { - const { root, objectsHelper, channel } = ctx; + const { entryInstance, objectsHelper, channel } = ctx; // create new map and set it on a root with forged timeserials const mapId = objectsHelper.fakeMapObjectId(); @@ -1538,7 +1497,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ]; expectedMapKeys.forEach(({ key, value }) => { - expect(root.get('map').get(key)).to.equal( + expect(entryInstance.get('map').get(key).value()).to.equal( value, `Check "${key}" key on map has expected value after MAP_SET ops`, ); @@ -1613,7 +1572,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'MAP_REMOVE object operation messages are applied based on the site timeserials vector of the object', action: async (ctx) => { - const { root, objectsHelper, channel } = ctx; + const { entryInstance, objectsHelper, channel } = ctx; // create new map and set it on a root with forged timeserials const mapId = objectsHelper.fakeMapObjectId(); @@ -1671,11 +1630,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expectedMapKeys.forEach(({ key, exists }) => { if (exists) { - expect(root.get('map').get(key), `Check "${key}" key on map still exists after MAP_REMOVE ops`).to - .exist; + expect(entryInstance.get('map').get(key), `Check "${key}" key on map still exists after MAP_REMOVE ops`) + .to.exist; } else { - expect(root.get('map').get(key), `Check "${key}" key on map does not exist after MAP_REMOVE ops`).to.not - .exist; + expect( + entryInstance.get('map').get(key), + `Check "${key}" key on map does not exist after MAP_REMOVE ops`, + ).to.not.exist; } }); }, @@ -1684,7 +1645,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'MAP_REMOVE for a map entry sets "tombstoneAt" from "serialTimestamp"', action: async (ctx) => { - const { helper, channel, root, objectsHelper } = ctx; + const { helper, channel, entryInstance, objectsHelper } = ctx; const serialTimestamp = 1234567890; await objectsHelper.processObjectOperationMessageOnChannel({ @@ -1695,8 +1656,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function state: [objectsHelper.mapRemoveOp({ objectId: 'root', key: 'foo' })], }); + helper.recordPrivateApi('read.DefaultInstance._value'); helper.recordPrivateApi('read.LiveMap._dataRef.data'); - const mapEntry = root._dataRef.data.get('foo'); + const mapEntry = entryInstance._value._dataRef.data.get('foo'); expect(mapEntry, 'Check map entry is added to root internal data after MAP_REMOVE for a map entry').to .exist; expect(mapEntry.tombstonedAt).to.equal( @@ -1709,7 +1671,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'MAP_REMOVE for a map entry sets "tombstoneAt" using local clock if missing "serialTimestamp"', action: async (ctx) => { - const { helper, channel, root, objectsHelper } = ctx; + const { helper, channel, entryInstance, objectsHelper } = ctx; const tsBeforeMsg = Date.now(); await objectsHelper.processObjectOperationMessageOnChannel({ @@ -1721,8 +1683,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); const tsAfterMsg = Date.now(); + helper.recordPrivateApi('read.DefaultInstance._value'); helper.recordPrivateApi('read.LiveMap._dataRef.data'); - const mapEntry = root._dataRef.data.get('foo'); + const mapEntry = entryInstance._value._dataRef.data.get('foo'); expect(mapEntry, 'Check map entry is added to root internal data after MAP_REMOVE for a map entry').to .exist; expect( @@ -1736,7 +1699,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'can apply COUNTER_CREATE object operation messages', action: async (ctx) => { - const { root, objectsHelper, channelName, entryInstance } = ctx; + const { objectsHelper, channelName, entryInstance, helper } = ctx; // Objects public API allows us to check value of objects we've created based on COUNTER_CREATE ops // if we assign those objects to another map (root for example), as there is no way to access those objects from the internal pool directly. @@ -1745,8 +1708,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // check no counters exist on root countersFixtures.forEach((fixture) => { const key = fixture.name; - expect(root.get(key), `Check "${key}" key doesn't exist on root before applying COUNTER_CREATE ops`).to - .not.exist; + expect( + entryInstance.get(key), + `Check "${key}" key doesn't exist on root before applying COUNTER_CREATE ops`, + ).to.not.exist; }); const countersCreatedPromise = Promise.all( @@ -1767,18 +1732,19 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // check created counters countersFixtures.forEach((fixture) => { const key = fixture.name; - const counterObj = root.get(key); + const counter = entryInstance.get(key); // check all counters exist on root - expect(counterObj, `Check counter at "${key}" key in root exists`).to.exist; + expect(counter, `Check counter at "${key}" key in root exists`).to.exist; + helper.recordPrivateApi('read.DefaultInstance._value'); expectInstanceOf( - counterObj, + counter._value, 'LiveCounter', `Check counter at "${key}" key in root is of type LiveCounter`, ); // check counters have correct values - expect(counterObj.value()).to.equal( + expect(counter.value()).to.equal( // if count was not set, should default to 0 fixture.count ?? 0, `Check counter at "${key}" key in root has correct value`, @@ -1791,7 +1757,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'COUNTER_CREATE object operation messages are applied based on the site timeserials vector of the object', action: async (ctx) => { - const { root, objectsHelper, channel } = ctx; + const { entryInstance, objectsHelper, channel } = ctx; // need to use multiple counters as COUNTER_CREATE op can only be applied once to a counter object const counterIds = [ @@ -1847,7 +1813,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function for (const [i, counterId] of counterIds.entries()) { const expectedValue = expectedCounterValues[i]; - expect(root.get(counterId).value()).to.equal( + expect(entryInstance.get(counterId).value()).to.equal( expectedValue, `Check counter #${i + 1} has expected value after COUNTER_CREATE ops`, ); @@ -1923,7 +1889,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'COUNTER_INC object operation messages are applied based on the site timeserials vector of the object', action: async (ctx) => { - const { root, objectsHelper, channel } = ctx; + const { entryInstance, objectsHelper, channel } = ctx; // create new counter and set it on a root with forged timeserials const counterId = objectsHelper.fakeCounterObjectId(); @@ -1958,7 +1924,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function } // check only operations with correct timeserials were applied - expect(root.get('counter').value()).to.equal( + expect(entryInstance.get('counter').value()).to.equal( 1 + 1000 + 100000 + 1000000, // sum of passing operations and the initial value `Check counter has expected value after COUNTER_INC ops`, ); @@ -1968,7 +1934,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'can apply OBJECT_DELETE object operation messages', action: async (ctx) => { - const { root, objectsHelper, channelName, channel, entryInstance } = ctx; + const { objectsHelper, channelName, channel, entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(entryInstance, 'map'), @@ -1987,8 +1953,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await objectsCreatedPromise; - expect(root.get('map'), 'Check map exists on root before OBJECT_DELETE').to.exist; - expect(root.get('counter'), 'Check counter exists on root before OBJECT_DELETE').to.exist; + expect(entryInstance.get('map'), 'Check map exists on root before OBJECT_DELETE').to.exist; + expect(entryInstance.get('counter'), 'Check counter exists on root before OBJECT_DELETE').to.exist; // inject OBJECT_DELETE await objectsHelper.processObjectOperationMessageOnChannel({ @@ -2004,15 +1970,16 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function state: [objectsHelper.objectDeleteOp({ objectId: counterObjectId })], }); - expect(root.get('map'), 'Check map is not accessible on root after OBJECT_DELETE').to.not.exist; - expect(root.get('counter'), 'Check counter is not accessible on root after OBJECT_DELETE').to.not.exist; + expect(entryInstance.get('map'), 'Check map is not accessible on root after OBJECT_DELETE').to.not.exist; + expect(entryInstance.get('counter'), 'Check counter is not accessible on root after OBJECT_DELETE').to.not + .exist; }, }, { description: 'OBJECT_DELETE for unknown object id creates zero-value tombstoned object', action: async (ctx) => { - const { root, objectsHelper, channel } = ctx; + const { entryInstance, objectsHelper, channel } = ctx; const counterId = objectsHelper.fakeCounterObjectId(); // inject OBJECT_DELETE. should create a zero-value tombstoned object which can't be modified @@ -2037,7 +2004,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function state: [objectsHelper.mapSetOp({ objectId: 'root', key: 'counter', data: { objectId: counterId } })], }); - expect(root.get('counter'), 'Check counter is not accessible on root').to.not.exist; + expect(entryInstance.get('counter'), 'Check counter is not accessible on root').to.not.exist; }, }, @@ -2045,7 +2012,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'OBJECT_DELETE object operation messages are applied based on the site timeserials vector of the object', action: async (ctx) => { - const { root, objectsHelper, channel } = ctx; + const { entryInstance, objectsHelper, channel } = ctx; // need to use multiple objects as OBJECT_DELETE op can only be applied once to an object const counterIds = [ @@ -2103,12 +2070,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function if (exists) { expect( - root.get(counterId), + entryInstance.get(counterId), `Check counter #${i + 1} exists on root as OBJECT_DELETE op was not applied`, ).to.exist; } else { expect( - root.get(counterId), + entryInstance.get(counterId), `Check counter #${i + 1} does not exist on root as OBJECT_DELETE op was applied`, ).to.not.exist; } @@ -2194,7 +2161,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'OBJECT_DELETE for an object sets "tombstoneAt" from "serialTimestamp"', action: async (ctx) => { - const { root, objectsHelper, channelName, channel, helper, realtimeObject, entryInstance } = ctx; + const { objectsHelper, channelName, channel, helper, realtimeObject, entryInstance } = ctx; const objectCreatedPromise = waitForMapKeyUpdate(entryInstance, 'object'); const { objectId } = await objectsHelper.createAndSetOnMap(channelName, { @@ -2204,7 +2171,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await objectCreatedPromise; - expect(root.get('object'), 'Check object exists on root before OBJECT_DELETE').to.exist; + expect(entryInstance.get('object'), 'Check object exists on root before OBJECT_DELETE').to.exist; // inject OBJECT_DELETE const serialTimestamp = 1234567890; @@ -2231,7 +2198,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'OBJECT_DELETE for an object sets "tombstoneAt" using local clock if missing "serialTimestamp"', action: async (ctx) => { - const { root, objectsHelper, channelName, channel, helper, realtimeObject, entryInstance } = ctx; + const { objectsHelper, channelName, channel, helper, realtimeObject, entryInstance } = ctx; const objectCreatedPromise = waitForMapKeyUpdate(entryInstance, 'object'); const { objectId } = await objectsHelper.createAndSetOnMap(channelName, { @@ -2241,7 +2208,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await objectCreatedPromise; - expect(root.get('object'), 'Check object exists on root before OBJECT_DELETE').to.exist; + expect(entryInstance.get('object'), 'Check object exists on root before OBJECT_DELETE').to.exist; const tsBeforeMsg = Date.now(); // inject OBJECT_DELETE @@ -2269,7 +2236,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'MAP_SET with reference to a tombstoned object results in undefined value on key', action: async (ctx) => { - const { root, objectsHelper, channelName, channel, entryInstance } = ctx; + const { objectsHelper, channelName, channel, entryInstance } = ctx; const objectCreatedPromise = waitForMapKeyUpdate(entryInstance, 'foo'); // create initial objects and set on root @@ -2280,7 +2247,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await objectCreatedPromise; - expect(root.get('foo'), 'Check counter exists on root before OBJECT_DELETE').to.exist; + expect(entryInstance.get('foo'), 'Check counter exists on root before OBJECT_DELETE').to.exist; // inject OBJECT_DELETE await objectsHelper.processObjectOperationMessageOnChannel({ @@ -2298,15 +2265,15 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function state: [objectsHelper.mapSetOp({ objectId: 'root', key: 'bar', data: { objectId: counterObjectId } })], }); - expect(root.get('bar'), 'Check counter is not accessible on new key in root after OBJECT_DELETE').to.not - .exist; + expect(entryInstance.get('bar'), 'Check counter is not accessible on new key in root after OBJECT_DELETE') + .to.not.exist; }, }, { description: 'object operation message on a tombstoned object does not revive it', action: async (ctx) => { - const { root, objectsHelper, channelName, channel, entryInstance } = ctx; + const { objectsHelper, channelName, channel, entryInstance } = ctx; const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(entryInstance, 'map1'), @@ -2331,9 +2298,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await objectsCreatedPromise; - expect(root.get('map1'), 'Check map1 exists on root before OBJECT_DELETE').to.exist; - expect(root.get('map2'), 'Check map2 exists on root before OBJECT_DELETE').to.exist; - expect(root.get('counter1'), 'Check counter1 exists on root before OBJECT_DELETE').to.exist; + expect(entryInstance.get('map1'), 'Check map1 exists on root before OBJECT_DELETE').to.exist; + expect(entryInstance.get('map2'), 'Check map2 exists on root before OBJECT_DELETE').to.exist; + expect(entryInstance.get('counter1'), 'Check counter1 exists on root before OBJECT_DELETE').to.exist; // inject OBJECT_DELETE await objectsHelper.processObjectOperationMessageOnChannel({ @@ -2376,12 +2343,16 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); // objects should still be deleted - expect(root.get('map1'), 'Check map1 does not exist on root after OBJECT_DELETE and another object op').to - .not.exist; - expect(root.get('map2'), 'Check map2 does not exist on root after OBJECT_DELETE and another object op').to - .not.exist; expect( - root.get('counter1'), + entryInstance.get('map1'), + 'Check map1 does not exist on root after OBJECT_DELETE and another object op', + ).to.not.exist; + expect( + entryInstance.get('map2'), + 'Check map2 does not exist on root after OBJECT_DELETE and another object op', + ).to.not.exist; + expect( + entryInstance.get('counter1'), 'Check counter1 does not exist on root after OBJECT_DELETE and another object op', ).to.not.exist; }, @@ -2392,7 +2363,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'object operation messages are buffered during OBJECT_SYNC sequence', action: async (ctx) => { - const { root, objectsHelper, channel, client, helper } = ctx; + const { entryInstance, objectsHelper, channel, client, helper } = ctx; // start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages await objectsHelper.processObjectStateMessageOnChannel({ @@ -2423,8 +2394,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // check root doesn't have data from operations primitiveKeyData.forEach((keyData) => { - expect(root.get(keyData.key), `Check "${keyData.key}" key doesn't exist on root during OBJECT_SYNC`).to - .not.exist; + expect( + entryInstance.get(keyData.key), + `Check "${keyData.key}" key doesn't exist on root during OBJECT_SYNC`, + ).to.not.exist; }); }, }, @@ -2432,7 +2405,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'buffered object operation messages are applied when OBJECT_SYNC sequence ends', action: async (ctx) => { - const { root, objectsHelper, channel, helper, client } = ctx; + const { entryInstance, objectsHelper, channel, helper, client } = ctx; // start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages await objectsHelper.processObjectStateMessageOnChannel({ @@ -2469,11 +2442,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // check everything is applied correctly primitiveKeyData.forEach((keyData) => { - checkKeyDataOnMap({ + checkKeyDataOnInstance({ helper, key: keyData.key, keyData, - mapObj: root, + instance: entryInstance, msg: `Check root has correct value for "${keyData.key}" key after OBJECT_SYNC has ended and buffered operations are applied`, }); }); @@ -2483,7 +2456,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'buffered object operation messages are discarded when new OBJECT_SYNC sequence starts', action: async (ctx) => { - const { root, objectsHelper, channel, client, helper } = ctx; + const { entryInstance, objectsHelper, channel, client, helper } = ctx; // start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages await objectsHelper.processObjectStateMessageOnChannel({ @@ -2535,13 +2508,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // check root doesn't have data from operations received during first sync primitiveKeyData.forEach((keyData) => { expect( - root.get(keyData.key), + entryInstance.get(keyData.key), `Check "${keyData.key}" key doesn't exist on root when OBJECT_SYNC has ended`, ).to.not.exist; }); // check root has data from operations received during second sync - expect(root.get('foo')).to.equal( + expect(entryInstance.get('foo').value()).to.equal( 'bar', 'Check root has data from operations received during second OBJECT_SYNC sequence', ); @@ -2552,7 +2525,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'buffered object operation messages are applied based on the site timeserials vector of the object', action: async (ctx) => { - const { root, objectsHelper, channel } = ctx; + const { entryInstance, objectsHelper, channel } = ctx; // start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages const mapId = objectsHelper.fakeMapObjectId(); @@ -2657,13 +2630,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ]; expectedMapKeys.forEach(({ key, value }) => { - expect(root.get('map').get(key)).to.equal( + expect(entryInstance.get('map').get(key).value()).to.equal( value, `Check "${key}" key on map has expected value after OBJECT_SYNC has ended`, ); }); - expect(root.get('counter').value()).to.equal( + expect(entryInstance.get('counter').value()).to.equal( 1 + 1000 + 100000 + 1000000, // sum of passing operations and the initial value `Check counter has expected value after OBJECT_SYNC has ended`, ); @@ -2674,7 +2647,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'subsequent object operation messages are applied immediately after OBJECT_SYNC ended and buffers are applied', action: async (ctx) => { - const { root, objectsHelper, channel, channelName, helper, client, entryInstance } = ctx; + const { objectsHelper, channel, channelName, helper, client, entryInstance } = ctx; // start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages await objectsHelper.processObjectStateMessageOnChannel({ @@ -2723,15 +2696,15 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // check buffered operations are applied, as well as the most recent operation outside of the sync sequence is applied primitiveKeyData.forEach((keyData) => { - checkKeyDataOnMap({ + checkKeyDataOnInstance({ helper, key: keyData.key, keyData, - mapObj: root, + instance: entryInstance, msg: `Check root has correct value for "${keyData.key}" key after OBJECT_SYNC has ended and buffered operations are applied`, }); }); - expect(root.get('foo')).to.equal( + expect(entryInstance.get('foo').value()).to.equal( 'bar', 'Check root has correct value for "foo" key from operation received outside of OBJECT_SYNC after other buffered operations were applied', ); @@ -2787,7 +2760,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'LiveCounter.increment throws on invalid input', action: async (ctx) => { - const { root, objectsHelper, channelName, entryInstance } = ctx; + const { objectsHelper, channelName, entryInstance } = ctx; const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); await objectsHelper.createAndSetOnMap(channelName, { @@ -2797,16 +2770,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await counterCreatedPromise; - const counter = root.get('counter'); + const counter = entryInstance.get('counter'); - await expectToThrowAsync( - async () => counter.increment(), - 'Counter value increment should be a valid number', - ); - await expectToThrowAsync( - async () => counter.increment(null), - 'Counter value increment should be a valid number', - ); await expectToThrowAsync( async () => counter.increment(Number.NaN), 'Counter value increment should be a valid number', @@ -2897,7 +2862,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'LiveCounter.decrement throws on invalid input', action: async (ctx) => { - const { root, objectsHelper, channelName, entryInstance } = ctx; + const { objectsHelper, channelName, entryInstance } = ctx; const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); await objectsHelper.createAndSetOnMap(channelName, { @@ -2907,16 +2872,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await counterCreatedPromise; - const counter = root.get('counter'); + const counter = entryInstance.get('counter'); - await expectToThrowAsync( - async () => counter.decrement(), - 'Counter value decrement should be a valid number', - ); - await expectToThrowAsync( - async () => counter.decrement(null), - 'Counter value decrement should be a valid number', - ); await expectToThrowAsync( async () => counter.decrement(Number.NaN), 'Counter value decrement should be a valid number', @@ -2964,7 +2921,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'LiveMap.set sends MAP_SET operation with primitive values', action: async (ctx) => { - const { root, helper, entryInstance } = ctx; + const { helper, entryInstance } = ctx; const keysUpdatedPromise = Promise.all( primitiveKeyData.map((x) => waitForMapKeyUpdate(entryInstance, x.key)), @@ -2981,18 +2938,18 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function value = keyData.data.number ?? keyData.data.string ?? keyData.data.boolean; } - await root.set(keyData.key, value); + await entryInstance.set(keyData.key, value); }), ); await keysUpdatedPromise; // check everything is applied correctly primitiveKeyData.forEach((keyData) => { - checkKeyDataOnMap({ + checkKeyDataOnInstance({ helper, key: keyData.key, keyData, - mapObj: root, + instance: entryInstance, msg: `Check root has correct value for "${keyData.key}" key after LiveMap.set call`, }); }); @@ -3003,50 +2960,32 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'LiveMap.set sends MAP_SET operation with reference to another LiveObject', action: async (ctx) => { - const { root, objectsHelper, channelName, entryInstance } = ctx; + const { entryInstance, helper } = ctx; - const objectsCreatedPromise = Promise.all([ + const keysUpdatedPromise = Promise.all([ waitForMapKeyUpdate(entryInstance, 'counter'), waitForMapKeyUpdate(entryInstance, 'map'), ]); - await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'counter', - createOp: objectsHelper.counterCreateRestOp(), - }); - await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'map', - createOp: objectsHelper.mapCreateRestOp(), - }); - await objectsCreatedPromise; - - const counter = root.get('counter'); - const map = root.get('map'); - - const keysUpdatedPromise = Promise.all([ - waitForMapKeyUpdate(entryInstance, 'counter2'), - waitForMapKeyUpdate(entryInstance, 'map2'), - ]); - await root.set('counter2', counter); - await root.set('map2', map); + await entryInstance.set('counter', LiveCounter.create(1)); + await entryInstance.set('map', LiveMap.create({ foo: 'bar' })); await keysUpdatedPromise; - expect(root.get('counter2')).to.equal( - counter, - 'Check can set a reference to a LiveCounter object on a root via a LiveMap.set call', - ); - expect(root.get('map2')).to.equal( - map, - 'Check can set a reference to a LiveMap object on a root via a LiveMap.set call', - ); + const counter = entryInstance.get('counter'); + const map = entryInstance.get('map'); + + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf(counter._value, 'LiveCounter', 'Check counter set on root is a LiveCounter object'); + expect(counter.value()).to.equal(1, 'Check counter initial value is correct'); + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf(map._value, 'LiveMap', 'Check map set on root is a LiveMap object'); + expect(map.get('foo').value()).to.equal('bar', 'Check map initial value is correct'); }, }, { description: 'LiveMap.set throws on invalid input', action: async (ctx) => { - const { root, objectsHelper, channelName, entryInstance } = ctx; + const { objectsHelper, channelName, entryInstance } = ctx; const mapCreatedPromise = waitForMapKeyUpdate(entryInstance, 'map'); await objectsHelper.createAndSetOnMap(channelName, { @@ -3056,7 +2995,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await mapCreatedPromise; - const map = root.get('map'); + const map = entryInstance.get('map'); await expectToThrowAsync(async () => map.set(), 'Map key should be string'); await expectToThrowAsync(async () => map.set(null), 'Map key should be string'); @@ -3114,7 +3053,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'LiveMap.remove throws on invalid input', action: async (ctx) => { - const { root, objectsHelper, channelName, entryInstance } = ctx; + const { objectsHelper, channelName, entryInstance } = ctx; const mapCreatedPromise = waitForMapKeyUpdate(entryInstance, 'map'); await objectsHelper.createAndSetOnMap(channelName, { @@ -3124,7 +3063,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await mapCreatedPromise; - const map = root.get('map'); + const map = entryInstance.get('map'); await expectToThrowAsync(async () => map.remove(), 'Map key should be string'); await expectToThrowAsync(async () => map.remove(null), 'Map key should be string'); @@ -3150,28 +3089,17 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'value type created with LiveCounter.create() can be assigned to the object tree', action: async (ctx) => { - const { root, entryInstance } = ctx; + const { entryInstance, helper } = ctx; const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); - await root.set('counter', LiveCounter.create(1)); + await entryInstance.set('counter', LiveCounter.create(1)); await counterCreatedPromise; - const counter = root.get('counter'); + const counter = entryInstance.get('counter'); - expectInstanceOf(counter, 'LiveCounter', `Check counter instance is of an expected class`); - expectInstanceOf( - root.get('counter'), - 'LiveCounter', - `Check counter instance on root is of an expected class`, - ); - expect(root.get('counter')).to.equal( - counter, - 'Check counter object on root is the same as from create method', - ); - expect(root.get('counter').value()).to.equal( - 1, - 'Check counter assigned to the object tree has the expected value', - ); + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf(counter._value, 'LiveCounter', `Check counter instance on root is of an expected class`); + expect(counter.value()).to.equal(1, 'Check counter assigned to the object tree has the expected value'); }, }, @@ -3179,20 +3107,27 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'LiveCounter.create() sends COUNTER_CREATE operation', action: async (ctx) => { - const { root, entryInstance } = ctx; + const { entryInstance, helper } = ctx; const objectsCreatedPromise = Promise.all( countersFixtures.map((x) => waitForMapKeyUpdate(entryInstance, x.name)), ); - await Promise.all(countersFixtures.map(async (x) => root.set(x.name, LiveCounter.create(x.count)))); + await Promise.all( + countersFixtures.map(async (x) => entryInstance.set(x.name, LiveCounter.create(x.count))), + ); await objectsCreatedPromise; for (let i = 0; i < countersFixtures.length; i++) { - const counter = root.get(countersFixtures[i].name); + const counter = entryInstance.get(countersFixtures[i].name); const fixture = countersFixtures[i]; expect(counter, `Check counter #${i + 1} exists`).to.exist; - expectInstanceOf(counter, 'LiveCounter', `Check counter instance #${i + 1} is of an expected class`); + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf( + counter._value, + 'LiveCounter', + `Check counter instance #${i + 1} is of an expected class`, + ); expect(counter.value()).to.equal( fixture.count ?? 0, `Check counter #${i + 1} has expected initial value`, @@ -3205,50 +3140,50 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'value type created with LiveCounter.create() with an invalid input throws when assigned to the object tree', action: async (ctx) => { - const { root } = ctx; + const { entryInstance } = ctx; await expectToThrowAsync( - async () => root.set('counter', LiveCounter.create(null)), + async () => entryInstance.set('counter', LiveCounter.create(null)), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => root.set('counter', LiveCounter.create(Number.NaN)), + async () => entryInstance.set('counter', LiveCounter.create(Number.NaN)), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => root.set('counter', LiveCounter.create(Number.POSITIVE_INFINITY)), + async () => entryInstance.set('counter', LiveCounter.create(Number.POSITIVE_INFINITY)), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => root.set('counter', LiveCounter.create(Number.NEGATIVE_INFINITY)), + async () => entryInstance.set('counter', LiveCounter.create(Number.NEGATIVE_INFINITY)), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => root.set('counter', LiveCounter.create('foo')), + async () => entryInstance.set('counter', LiveCounter.create('foo')), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => root.set('counter', LiveCounter.create(BigInt(1))), + async () => entryInstance.set('counter', LiveCounter.create(BigInt(1))), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => root.set('counter', LiveCounter.create(true)), + async () => entryInstance.set('counter', LiveCounter.create(true)), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => root.set('counter', LiveCounter.create(Symbol())), + async () => entryInstance.set('counter', LiveCounter.create(Symbol())), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => root.set('counter', LiveCounter.create({})), + async () => entryInstance.set('counter', LiveCounter.create({})), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => root.set('counter', LiveCounter.create([])), + async () => entryInstance.set('counter', LiveCounter.create([])), 'Counter value should be a valid number', ); await expectToThrowAsync( - async () => root.set('counter', LiveCounter.create(root)), + async () => entryInstance.set('counter', LiveCounter.create(entryInstance)), 'Counter value should be a valid number', ); }, @@ -3266,17 +3201,18 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'value type created with LiveMap.create() can be assigned to the object tree', action: async (ctx) => { - const { root, entryInstance } = ctx; + const { entryInstance, helper } = ctx; const mapCreatedPromise = waitForMapKeyUpdate(entryInstance, 'map'); - await root.set('map', LiveMap.create({ foo: 'bar' })); + await entryInstance.set('map', LiveMap.create({ foo: 'bar' })); await mapCreatedPromise; - const map = root.get('map'); + const map = entryInstance.get('map'); - expectInstanceOf(map, 'LiveMap', `Check map instance on root is of an expected class`); + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf(map._value, 'LiveMap', `Check map instance on root is of an expected class`); expect(map.size()).to.equal(1, 'Check map assigned to the object tree has the expected number of keys'); - expect(map.get('foo')).to.equal( + expect(map.get('foo').value()).to.equal( 'bar', 'Check map assigned to the object tree has the expected value for its string key', ); @@ -3287,7 +3223,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'LiveMap.create() sends MAP_CREATE operation with primitive values', action: async (ctx) => { - const { root, helper, entryInstance } = ctx; + const { helper, entryInstance } = ctx; const objectsCreatedPromise = Promise.all( primitiveMapsFixtures.map((x) => waitForMapKeyUpdate(entryInstance, x.name)), @@ -3311,17 +3247,18 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, {}) : undefined; - return root.set(mapFixture.name, LiveMap.create(entries)); + return entryInstance.set(mapFixture.name, LiveMap.create(entries)); }), ); await objectsCreatedPromise; for (let i = 0; i < primitiveMapsFixtures.length; i++) { - const map = root.get(primitiveMapsFixtures[i].name); + const map = entryInstance.get(primitiveMapsFixtures[i].name); const fixture = primitiveMapsFixtures[i]; expect(map, `Check map #${i + 1} exists`).to.exist; - expectInstanceOf(map, 'LiveMap', `Check map instance #${i + 1} is of an expected class`); + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf(map._value, 'LiveMap', `Check map instance #${i + 1} is of an expected class`); expect(map.size()).to.equal( Object.keys(fixture.entries ?? {}).length, @@ -3329,11 +3266,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ); Object.entries(fixture.entries ?? {}).forEach(([key, keyData]) => { - checkKeyDataOnMap({ + checkKeyDataOnInstance({ helper, key, keyData, - mapObj: map, + instance: map, msg: `Check map #${i + 1} has correct value for "${key}" key`, }); }); @@ -3345,10 +3282,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'LiveMap.create() sends MAP_CREATE operation with reference to another LiveObject', action: async (ctx) => { - const { root, entryInstance } = ctx; + const { entryInstance, helper } = ctx; const objectCreatedPromise = waitForMapKeyUpdate(entryInstance, 'map'); - await root.set( + await entryInstance.set( 'map', LiveMap.create({ map: LiveMap.create(), @@ -3357,18 +3294,25 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ); await objectCreatedPromise; - const map = root.get('map'); + const map = entryInstance.get('map'); const nestedMap = map.get('map'); const nestedCounter = map.get('counter'); expect(map, 'Check map exists').to.exist; - expectInstanceOf(map, 'LiveMap', 'Check map instance is of an expected class'); + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf(map._value, 'LiveMap', 'Check map instance is of an expected class'); expect(nestedMap, 'Check nested map exists').to.exist; - expectInstanceOf(nestedMap, 'LiveMap', 'Check nested map instance is of an expected class'); + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf(nestedMap._value, 'LiveMap', 'Check nested map instance is of an expected class'); expect(nestedCounter, 'Check nested counter exists').to.exist; - expectInstanceOf(nestedCounter, 'LiveCounter', 'Check nested counter instance is of an expected class'); + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf( + nestedCounter._value, + 'LiveCounter', + 'Check nested counter instance is of an expected class', + ); }, }, @@ -3376,47 +3320,47 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function description: 'value type created with LiveMap.create() with an invalid input throws when assigned to the object tree', action: async (ctx) => { - const { root } = ctx; + const { entryInstance } = ctx; await expectToThrowAsync( - async () => root.set('map', LiveMap.create(null)), + async () => entryInstance.set('map', LiveMap.create(null)), 'Map entries should be a key-value object', ); await expectToThrowAsync( - async () => root.set('map', LiveMap.create('foo')), + async () => entryInstance.set('map', LiveMap.create('foo')), 'Map entries should be a key-value object', ); await expectToThrowAsync( - async () => root.set('map', LiveMap.create(1)), + async () => entryInstance.set('map', LiveMap.create(1)), 'Map entries should be a key-value object', ); await expectToThrowAsync( - async () => root.set('map', LiveMap.create(BigInt(1))), + async () => entryInstance.set('map', LiveMap.create(BigInt(1))), 'Map entries should be a key-value object', ); await expectToThrowAsync( - async () => root.set('map', LiveMap.create(true)), + async () => entryInstance.set('map', LiveMap.create(true)), 'Map entries should be a key-value object', ); await expectToThrowAsync( - async () => root.set('map', LiveMap.create(Symbol())), + async () => entryInstance.set('map', LiveMap.create(Symbol())), 'Map entries should be a key-value object', ); await expectToThrowAsync( - async () => root.set('map', LiveMap.create({ key: undefined })), + async () => entryInstance.set('map', LiveMap.create({ key: undefined })), 'Map value data type is unsupported', ); await expectToThrowAsync( - async () => root.set('map', LiveMap.create({ key: null })), + async () => entryInstance.set('map', LiveMap.create({ key: null })), 'Map value data type is unsupported', ); await expectToThrowAsync( - async () => root.set('map', LiveMap.create({ key: BigInt(1) })), + async () => entryInstance.set('map', LiveMap.create({ key: BigInt(1) })), 'Map value data type is unsupported', ); await expectToThrowAsync( - async () => root.set('map', LiveMap.create({ key: Symbol() })), + async () => entryInstance.set('map', LiveMap.create({ key: Symbol() })), 'Map value data type is unsupported', ); }, @@ -3719,70 +3663,9 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, ]; - const liveMapEnumerationScenarios = [ - { - description: `LiveMap enumeration`, - action: async (ctx) => { - const { root, objectsHelper, channel } = ctx; - - const counterId1 = objectsHelper.fakeCounterObjectId(); - const counterId2 = objectsHelper.fakeCounterObjectId(); - await objectsHelper.processObjectStateMessageOnChannel({ - channel, - syncSerial: 'serial:', // empty serial so sync sequence ends immediately - state: [ - objectsHelper.counterObject({ - objectId: counterId1, - siteTimeserials: { - aaa: lexicoTimeserial('aaa', 0, 0), - }, - tombstone: false, - initialCount: 0, - }), - objectsHelper.counterObject({ - objectId: counterId2, - siteTimeserials: { - aaa: lexicoTimeserial('aaa', 0, 0), - }, - tombstone: true, - initialCount: 0, - }), - objectsHelper.mapObject({ - objectId: 'root', - siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, - materialisedEntries: { - counter1: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: counterId1 } }, - counter2: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: counterId2 } }, - foo: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { string: 'bar' } }, - baz: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { string: 'qux' }, tombstone: true }, - }, - }), - ], - }); - - const counter1 = await root.get('counter1'); - - // enumeration methods should not count tombstoned entries - expect(root.size()).to.equal(2, 'Check LiveMap.size() returns expected number of keys'); - expect([...root.entries()]).to.deep.equal( - [ - ['counter1', counter1], - ['foo', 'bar'], - ], - 'Check LiveMap.entries() returns expected entries', - ); - expect([...root.keys()]).to.deep.equal(['counter1', 'foo'], 'Check LiveMap.keys() returns expected keys'); - expect([...root.values()]).to.deep.equal( - [counter1, 'bar'], - 'Check LiveMap.values() returns expected values', - ); - }, - }, - ]; - const pathObjectScenarios = [ { - description: 'RealtimeObject.getPathObject() returns PathObject instance', + description: 'RealtimeObject.get() returns PathObject instance', action: async (ctx) => { const { entryPathObject } = ctx; @@ -3860,11 +3743,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.at() navigates using dot-separated paths', action: async (ctx) => { - const { root, entryPathObject, entryInstance } = ctx; + const { entryPathObject, entryInstance } = ctx; // Create nested structure const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'nested'); - await root.set('nested', LiveMap.create({ deepKey: 'deepValue', 'key.with.dots': 'dottedValue' })); + await entryPathObject.set( + 'nested', + LiveMap.create({ deepKey: 'deepValue', 'key.with.dots': 'dottedValue' }), + ); await keyUpdatedPromise; const nestedPathObj = entryPathObject.at('nested.deepKey'); @@ -3888,10 +3774,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject resolves complex path strings', action: async (ctx) => { - const { root, entryPathObject, entryInstance } = ctx; + const { entryPathObject, entryInstance } = ctx; const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'nested.key'); - await root.set( + await entryPathObject.set( 'nested.key', LiveMap.create({ 'key.with.dots.and\\escaped\\characters': 'nestedValue', @@ -3926,7 +3812,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.value() returns primitive values correctly', action: async (ctx) => { - const { root, entryPathObject, helper, entryInstance } = ctx; + const { entryPathObject, helper, entryInstance } = ctx; const keysUpdatedPromise = Promise.all( primitiveKeyData.map((x) => waitForMapKeyUpdate(entryInstance, x.key)), @@ -3943,7 +3829,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function value = keyData.data.number ?? keyData.data.string ?? keyData.data.boolean; } - await root.set(keyData.key, value); + await entryPathObject.set(keyData.key, value); }), ); await keysUpdatedPromise; @@ -3954,7 +3840,6 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function helper, key: keyData.key, keyData, - mapObj: root, pathObject: entryPathObject, msg: `Check PathObject returns correct value for "${keyData.key}" key after LiveMap.set call`, }); @@ -3965,10 +3850,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.value() returns LiveCounter values', action: async (ctx) => { - const { root, entryPathObject, entryInstance } = ctx; + const { entryPathObject, entryInstance } = ctx; const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); - await root.set('counter', LiveCounter.create(10)); + await entryPathObject.set('counter', LiveCounter.create(10)); await keyUpdatedPromise; const counterPathObj = entryPathObject.get('counter'); @@ -3980,14 +3865,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.instance() returns DefaultInstance for LiveMap and LiveCounter', action: async (ctx) => { - const { root, entryPathObject, entryInstance } = ctx; + const { entryPathObject, entryInstance } = ctx; const keysUpdatedPromise = Promise.all([ waitForMapKeyUpdate(entryInstance, 'map'), waitForMapKeyUpdate(entryInstance, 'counter'), ]); - await root.set('map', LiveMap.create()); - await root.set('counter', LiveCounter.create()); + await entryPathObject.set('map', LiveMap.create()); + await entryPathObject.set('counter', LiveCounter.create()); await keysUpdatedPromise; const counterInstance = entryPathObject.get('counter').instance(); @@ -4003,17 +3888,16 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject collection methods work for LiveMap objects', action: async (ctx) => { - const { root, entryPathObject, entryInstance } = ctx; + const { entryPathObject, entryInstance } = ctx; - // Set up test data const keysUpdatedPromise = Promise.all([ waitForMapKeyUpdate(entryInstance, 'key1'), waitForMapKeyUpdate(entryInstance, 'key2'), waitForMapKeyUpdate(entryInstance, 'key3'), ]); - await root.set('key1', 'value1'); - await root.set('key2', 'value2'); - await root.set('key3', 'value3'); + await entryPathObject.set('key1', 'value1'); + await entryPathObject.set('key2', 'value2'); + await entryPathObject.set('key3', 'value3'); await keysUpdatedPromise; // Test size @@ -4033,19 +3917,23 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const entryValues = entries.map(([key, pathObj]) => pathObj.value()); expect(entryValues).to.have.members(['value1', 'value2', 'value3'], 'Check PathObject entries values'); + expectInstanceOf(entries[0][1], 'DefaultPathObject', 'Check entry value is DefaultPathObject'); + // Test values const values = [...entryPathObject.values()]; expect(values).to.have.lengthOf(3, 'Check PathObject values length'); const valueValues = values.map((pathObj) => pathObj.value()); expect(valueValues).to.have.members(['value1', 'value2', 'value3'], 'Check PathObject values'); + + expectInstanceOf(values[0], 'DefaultPathObject', 'Check value is DefaultPathObject'); }, }, { description: 'PathObject.set() works for LiveMap objects with primitive values', action: async (ctx) => { - const { root, entryPathObject, helper, entryInstance } = ctx; + const { entryPathObject, helper, entryInstance } = ctx; const keysUpdatedPromise = Promise.all( primitiveKeyData.map((x) => waitForMapKeyUpdate(entryInstance, x.key)), @@ -4073,7 +3961,6 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function helper, key: keyData.key, keyData, - mapObj: root, pathObject: entryPathObject, msg: `Check PathObject returns correct value for "${keyData.key}" key after PathObject.set call`, }); @@ -4084,13 +3971,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.set() works for LiveMap objects with LiveObject references', action: async (ctx) => { - const { root, entryPathObject, entryInstance } = ctx; + const { entryPathObject, entryInstance } = ctx; const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counterKey'); await entryPathObject.set('counterKey', LiveCounter.create(5)); await keyUpdatedPromise; - expect(root.get('counterKey'), 'Check counter object was set via PathObject').to.exist; + expect(entryInstance.get('counterKey'), 'Check counter object was set via PathObject').to.exist; expect(entryPathObject.get('counterKey').value()).to.equal(5, 'Check PathObject reflects counter value'); }, }, @@ -4098,19 +3985,20 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.remove() works for LiveMap objects', action: async (ctx) => { - const { root, entryPathObject, entryInstance } = ctx; + const { entryPathObject, entryInstance } = ctx; const keyAddedPromise = waitForMapKeyUpdate(entryInstance, 'keyToRemove'); - await root.set('keyToRemove', 'valueToRemove'); + await entryPathObject.set('keyToRemove', 'valueToRemove'); await keyAddedPromise; - expect(root.get('keyToRemove'), 'Check key exists on root').to.exist; + expect(entryPathObject.get('keyToRemove'), 'Check key exists on root').to.exist; const keyRemovedPromise = waitForMapKeyUpdate(entryInstance, 'keyToRemove'); await entryPathObject.remove('keyToRemove'); await keyRemovedPromise; - expect(root.get('keyToRemove'), 'Check key on root is removed after PathObject.remove()').to.be.undefined; + expect(entryInstance.get('keyToRemove'), 'Check key on root is removed after PathObject.remove()').to.be + .undefined; expect( entryPathObject.get('keyToRemove').value(), 'Check value for path is undefined after PathObject.remove()', @@ -4241,10 +4129,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject handling of operations for paths with non-collection intermediate segments', action: async (ctx) => { - const { root, entryPathObject, entryInstance } = ctx; + const { entryPathObject, entryInstance } = ctx; const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); - await root.set('counter', LiveCounter.create()); + await entryPathObject.set('counter', LiveCounter.create()); await keyUpdatedPromise; const wrongTypePathObj = entryPathObject.at('counter.nested.path'); @@ -4292,16 +4180,16 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject handling of operations on wrong underlying object type', action: async (ctx) => { - const { root, entryPathObject, entryInstance } = ctx; + const { entryPathObject, entryInstance } = ctx; const keysUpdatedPromise = Promise.all([ waitForMapKeyUpdate(entryInstance, 'map'), waitForMapKeyUpdate(entryInstance, 'counter'), waitForMapKeyUpdate(entryInstance, 'primitive'), ]); - await root.set('map', LiveMap.create()); - await root.set('counter', LiveCounter.create()); - await root.set('primitive', 'value'); + await entryPathObject.set('map', LiveMap.create()); + await entryPathObject.set('counter', LiveCounter.create()); + await entryPathObject.set('primitive', 'value'); await keysUpdatedPromise; const mapPathObj = entryPathObject.get('map'); @@ -4392,7 +4280,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'PathObject.subscribe() receives events for direct changes to the subscribed path', action: async (ctx) => { - const { root, entryPathObject } = ctx; + const { entryPathObject } = ctx; const subscriptionPromise = new Promise((resolve, reject) => { entryPathObject.subscribe((event) => { @@ -4407,7 +4295,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); }); - await root.set('testKey', 'testValue'); + await entryPathObject.set('testKey', 'testValue'); await subscriptionPromise; }, }, @@ -5228,33 +5116,31 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const instanceScenarios = [ { - description: 'DefaultInstance.id() returns object ID of underlying LiveObject', + description: 'DefaultInstance.id() returns object ID of the underlying LiveObject', action: async (ctx) => { - const { root, entryPathObject, helper, entryInstance } = ctx; + const { objectsHelper, channelName, entryInstance } = ctx; const keysUpdatedPromise = Promise.all([ waitForMapKeyUpdate(entryInstance, 'map'), waitForMapKeyUpdate(entryInstance, 'counter'), ]); - - await entryPathObject.set('map', LiveMap.create()); - await entryPathObject.set('counter', LiveCounter.create()); + const { objectId: mapId } = await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'map', + createOp: objectsHelper.mapCreateRestOp(), + }); + const { objectId: counterId } = await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'counter', + createOp: objectsHelper.counterCreateRestOp(), + }); await keysUpdatedPromise; - const map = root.get('map'); - const counter = root.get('counter'); - - const mapInstance = entryPathObject.get('map').instance(); - const counterInstance = entryPathObject.get('counter').instance(); - - helper.recordPrivateApi('call.LiveObject.getObjectId'); - expect(mapInstance.id()).to.equal(map.getObjectId(), 'Check map instance ID matches underlying LiveMap ID'); + const map = entryInstance.get('map'); + const counter = entryInstance.get('counter'); - helper.recordPrivateApi('call.LiveObject.getObjectId'); - expect(counterInstance.id()).to.equal( - counter.getObjectId(), - 'Check counter instance ID matches underlying LiveCounter ID', - ); + expect(map.id()).to.equal(mapId, 'Check DefaultInstance.id() for map matches expected value'); + expect(counter.id()).to.equal(counterId, 'Check DefaultInstance.id() for counter matches expected value'); }, }, @@ -5344,7 +5230,6 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function action: async (ctx) => { const { entryPathObject, entryInstance } = ctx; - // Set up test data const keysUpdatedPromise = Promise.all([ waitForMapKeyUpdate(entryInstance, 'key1'), waitForMapKeyUpdate(entryInstance, 'key2'), @@ -5374,12 +5259,16 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const entryValues = entries.map(([key, instance]) => instance.value()); expect(entryValues).to.have.members(['value1', 'value2', 'value3'], 'Check DefaultInstance entries values'); + expectInstanceOf(entries[0][1], 'DefaultInstance', 'Check entry value is DefaultInstance'); + // Test values const values = [...rootInstance.values()]; expect(values).to.have.lengthOf(3, 'Check DefaultInstance values length'); const valueValues = values.map((instance) => instance.value()); expect(valueValues).to.have.members(['value1', 'value2', 'value3'], 'Check DefaultInstance values'); + + expectInstanceOf(values[0], 'DefaultInstance', 'Check value is DefaultInstance'); }, }, @@ -5426,25 +5315,21 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.set() works for LiveMap objects with LiveObject references', action: async (ctx) => { - const { root, entryPathObject, entryInstance } = ctx; - - const rootInstance = entryPathObject.instance(); + const { entryInstance } = ctx; const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counterKey'); - await rootInstance.set('counterKey', LiveCounter.create(5)); + await entryInstance.set('counterKey', LiveCounter.create(5)); await keyUpdatedPromise; - expect(root.get('counterKey'), 'Check counter object was set via DefaultInstance').to.exist; - expect(rootInstance.get('counterKey').value()).to.equal(5, 'Check DefaultInstance reflects counter value'); + expect(entryInstance.get('counterKey'), 'Check counter object was set via DefaultInstance').to.exist; + expect(entryInstance.get('counterKey').value()).to.equal(5, 'Check DefaultInstance reflects counter value'); }, }, { description: 'DefaultInstance.remove() works for LiveMap objects', action: async (ctx) => { - const { root, entryPathObject, entryInstance } = ctx; - - const rootInstance = entryPathObject.instance(); + const { entryPathObject, entryInstance } = ctx; const keyAddedPromise = waitForMapKeyUpdate(entryInstance, 'keyToRemove'); await entryPathObject.set('keyToRemove', 'valueToRemove'); @@ -5453,13 +5338,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expect(entryPathObject.get('keyToRemove').value(), 'Check key exists on root').to.exist; const keyRemovedPromise = waitForMapKeyUpdate(entryInstance, 'keyToRemove'); - await rootInstance.remove('keyToRemove'); + await entryInstance.remove('keyToRemove'); await keyRemovedPromise; - expect(root.get('keyToRemove'), 'Check key on root is removed after DefaultInstance.remove()').to.be - .undefined; expect( - rootInstance.get('keyToRemove'), + entryInstance.get('keyToRemove'), 'Check value for instance is undefined after DefaultInstance.remove()', ).to.be.undefined; }, @@ -5468,45 +5351,38 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function { description: 'DefaultInstance.increment() and DefaultInstance.decrement() work for LiveCounter objects', action: async (ctx) => { - const { root, entryPathObject, entryInstance } = ctx; - - const rootInstance = entryPathObject.instance(); + const { entryPathObject, entryInstance } = ctx; const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); await entryPathObject.set('counter', LiveCounter.create(10)); await keyUpdatedPromise; - const counter = root.get('counter'); - const counterInstance = rootInstance.get('counter'); + const counter = entryInstance.get('counter'); - let counterUpdatedPromise = waitForCounterUpdate(counterInstance); - await counterInstance.increment(5); + let counterUpdatedPromise = waitForCounterUpdate(counter); + await counter.increment(5); await counterUpdatedPromise; - expect(counter.value()).to.equal(15, 'Check counter incremented via DefaultInstance'); - expect(counterInstance.value()).to.equal(15, 'Check DefaultInstance reflects incremented value'); + expect(counter.value()).to.equal(15, 'Check DefaultInstance reflects incremented value'); - counterUpdatedPromise = waitForCounterUpdate(counterInstance); - await counterInstance.decrement(3); + counterUpdatedPromise = waitForCounterUpdate(counter); + await counter.decrement(3); await counterUpdatedPromise; - expect(counter.value()).to.equal(12, 'Check counter decremented via DefaultInstance'); - expect(counterInstance.value()).to.equal(12, 'Check DefaultInstance reflects decremented value'); + expect(counter.value()).to.equal(12, 'Check DefaultInstance reflects decremented value'); // test increment/decrement without argument (should increment/decrement by 1) - counterUpdatedPromise = waitForCounterUpdate(counterInstance); - await counterInstance.increment(); + counterUpdatedPromise = waitForCounterUpdate(counter); + await counter.increment(); await counterUpdatedPromise; - expect(counter.value()).to.equal(13, 'Check counter incremented via DefaultInstance without argument'); - expect(counterInstance.value()).to.equal(13, 'Check DefaultInstance reflects incremented value'); + expect(counter.value()).to.equal(13, 'Check DefaultInstance reflects incremented value'); - counterUpdatedPromise = waitForCounterUpdate(counterInstance); - await counterInstance.decrement(); + counterUpdatedPromise = waitForCounterUpdate(counter); + await counter.decrement(); await counterUpdatedPromise; - expect(counter.value()).to.equal(12, 'Check counter decremented via DefaultInstance without argument'); - expect(counterInstance.value()).to.equal(12, 'Check DefaultInstance reflects decremented value'); + expect(counter.value()).to.equal(12, 'Check DefaultInstance reflects decremented value'); }, }, @@ -6233,7 +6109,6 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ...applyOperationsScenarios, ...applyOperationsDuringSyncScenarios, ...writeApiScenarios, - ...liveMapEnumerationScenarios, ...pathObjectScenarios, ...instanceScenarios, ], @@ -6246,13 +6121,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const realtimeObject = channel.object; await channel.attach(); - const root = await realtimeObject.get(); - const entryPathObject = await realtimeObject.getPathObject(); + const entryPathObject = await realtimeObject.get(); const entryInstance = entryPathObject.instance(); await scenario.action({ realtimeObject, - root, entryPathObject, entryInstance, objectsHelper, @@ -6780,8 +6653,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const channel = client.channels.get(channelName, channelOptionsWithObjects()); await channel.attach(); - const root = await channel.object.get(); - const entryPathObject = await channel.object.getPathObject(); + const entryPathObject = await channel.object.get(); const entryInstance = entryPathObject.instance(); const sampleMapKey = 'sampleMap'; @@ -6805,7 +6677,6 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await objectsCreatedPromise; await scenario.action({ - root, entryPathObject, entryInstance, objectsHelper, @@ -6969,7 +6840,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function allTransportsAndProtocols: true, description: 'tombstoned map entry is removed from the LiveMap after the GC grace period', action: async (ctx) => { - const { root, objectsHelper, channelName, helper, waitForGCCycles, entryInstance } = ctx; + const { entryInstance, objectsHelper, channelName, helper, waitForGCCycles } = ctx; const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'foo'); // set a key on a root @@ -6979,7 +6850,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ); await keyUpdatedPromise; - expect(root.get('foo')).to.equal('bar', 'Check key "foo" exists on root after MAP_SET'); + expect(entryInstance.get('foo').value()).to.equal('bar', 'Check key "foo" exists on root after MAP_SET'); const keyUpdatedPromise2 = waitForMapKeyUpdate(entryInstance, 'foo'); // remove the key from the root. this should tombstone the map entry and make it inaccessible to the end user, but still keep it in memory in the underlying map @@ -6989,16 +6860,18 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ); await keyUpdatedPromise2; - expect(root.get('foo'), 'Check key "foo" is inaccessible via public API on root after MAP_REMOVE').to.not - .exist; + expect(entryInstance.get('foo'), 'Check key "foo" is inaccessible via public API on root after MAP_REMOVE') + .to.not.exist; + helper.recordPrivateApi('read.DefaultInstance._value'); helper.recordPrivateApi('read.LiveMap._dataRef.data'); expect( - root._dataRef.data.get('foo'), + entryInstance._value._dataRef.data.get('foo'), 'Check map entry for "foo" exists on root in the underlying data immediately after MAP_REMOVE', ).to.exist; + helper.recordPrivateApi('read.DefaultInstance._value'); helper.recordPrivateApi('read.LiveMap._dataRef.data'); expect( - root._dataRef.data.get('foo').tombstone, + entryInstance._value._dataRef.data.get('foo').tombstone, 'Check map entry for "foo" on root has "tombstone" flag set to "true" after MAP_REMOVE', ).to.exist; @@ -7006,9 +6879,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await waitForGCCycles(2); // the entry should be removed from the underlying map now + helper.recordPrivateApi('read.DefaultInstance._value'); helper.recordPrivateApi('read.LiveMap._dataRef.data'); expect( - root._dataRef.data.get('foo'), + entryInstance._value._dataRef.data.get('foo'), 'Check map entry for "foo" does not exist on root in the underlying data after the GC grace period expiration', ).to.not.exist; }, @@ -7029,8 +6903,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const realtimeObject = channel.object; await channel.attach(); - const root = await channel.object.get(); - const entryPathObject = await channel.object.getPathObject(); + const entryPathObject = await channel.object.get(); const entryInstance = entryPathObject.instance(); helper.recordPrivateApi('read.RealtimeObject.gcGracePeriod'); @@ -7060,7 +6933,6 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await scenario.action({ client, - root, entryPathObject, entryInstance, objectsHelper, @@ -7289,16 +7161,15 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const realtimeObject = channel.object; await channel.attach(); - const root = await channel.object.get(); - const entryPathObject = await channel.object.getPathObject(); + const entryPathObject = await channel.object.get(); const entryInstance = entryPathObject.instance(); const objectsCreatedPromise = Promise.all([ waitForMapKeyUpdate(entryInstance, 'map'), waitForMapKeyUpdate(entryInstance, 'counter'), ]); - await root.set('map', LiveMap.create()); - await root.set('counter', LiveCounter.create()); + await entryInstance.set('map', LiveMap.create()); + await entryInstance.set('counter', LiveCounter.create()); await objectsCreatedPromise; const map = entryInstance.get('map'); @@ -7309,7 +7180,6 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function objectsHelper, channelName, channel, - root, entryPathObject, entryInstance, map, @@ -7355,11 +7225,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const channel = client.channels.get('channel', channelOptionsWithObjects()); await channel.attach(); - const root = await channel.object.get(); + const entryPathObject = await channel.object.get(); const data = new Array(100).fill('a').join(''); const error = await expectToThrowAsync( - async () => root.set('key', data), + async () => entryPathObject.set('key', data), 'Maximum size of object messages that can be published at once exceeded', ); From 093fd4ba0e4605b9464e8bdab261d3460ee90e90 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Wed, 19 Nov 2025 10:01:35 +0000 Subject: [PATCH 21/45] Remove AblyObjectsTypes and expect the types for the object structure to be provided explicitly Resolves PUB-3439 --- ably.d.ts | 39 ++----------------- src/plugins/objects/realtimeobject.ts | 2 +- .../browser/template/src/index-objects.ts | 27 +++---------- typedoc.json | 3 +- 4 files changed, 12 insertions(+), 59 deletions(-) diff --git a/ably.d.ts b/ably.d.ts index 57c1e6ddb1..6d1a388e76 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2314,30 +2314,24 @@ export declare interface RealtimeObject { /** * Retrieves a {@link PathObject} for the object on a channel. * - * A type parameter can be provided to describe the structure of the Objects on the channel. By default, it uses types from the globally defined `AblyObjectsTypes` interface. - * - * You can specify custom types for Objects by defining a global `AblyObjectsTypes` interface with an `object` property that conforms to Record type. + * A type parameter can be provided to describe the structure of the Objects on the channel. * * Example: * * ```typescript - * import { LiveCounter } from 'ably/objects'; + * import { LiveCounter } from 'ably'; * * type MyObject = { * myTypedCounter: LiveCounter; * }; * - * declare global { - * export interface AblyObjectsTypes { - * object: MyObject; - * } - * } + * const myTypedObject = await channel.object.get(); * ``` * * @returns A promise which, upon success, will be fulfilled with a {@link PathObject}. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. * @experimental */ - get = AblyDefaultObject>(): Promise>>; + get>(): Promise>>; /** * Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. @@ -2468,31 +2462,6 @@ export type CompactedValue = ? T : any; -declare global { - /** - * A globally defined interface that allows users to define custom types for Objects. - */ - export interface AblyObjectsTypes { - [key: string]: unknown; - } -} - -/** - * The default type for the channel object return from the {@link RealtimeObject.get}, based on the globally defined {@link AblyObjectsTypes} interface. - * - * - If no custom types are provided in `AblyObjectsTypes`, defaults to an untyped map representation using the Record type. - * - If an `object` key exists in `AblyObjectsTypes` and its type conforms to the Record, it is used as the type for the object returned from the {@link RealtimeObject.get}. - * - If the provided type in `object` key does not match Record, a type error message is returned. - */ -export type AblyDefaultObject = - // we need a way to know when no types were provided by the user. - // we expect an "object" property to be set on AblyObjectsTypes interface, e.g. it won't be "unknown" anymore - unknown extends AblyObjectsTypes['object'] - ? Record // no custom types provided; use the default untyped map representation for the entrypoint map - : AblyObjectsTypes['object'] extends Record - ? AblyObjectsTypes['object'] // "object" property exists, and it is of an expected type, we can use this interface for the entrypoint map - : `Provided type definition for the channel \`object\` in AblyObjectsTypes is not of an expected Record type`; - /** * PathObjectBase defines the set of common methods on a PathObject * that are present regardless of the underlying type. diff --git a/src/plugins/objects/realtimeobject.ts b/src/plugins/objects/realtimeobject.ts index 3e4fa5b175..0f3aa56741 100644 --- a/src/plugins/objects/realtimeobject.ts +++ b/src/plugins/objects/realtimeobject.ts @@ -79,7 +79,7 @@ export class RealtimeObject { * A user can provide an explicit type for the this method to explicitly set the type structure on this particular channel. * This is useful when working with multiple channels with different underlying data structure. */ - async get = API.AblyDefaultObject>(): Promise>> { + async get>(): Promise>> { this.throwIfInvalidAccessApiConfiguration(); // RTO1a, RTO1b // if we're not synced yet, wait for sync sequence to finish before returning root diff --git a/test/package/browser/template/src/index-objects.ts b/test/package/browser/template/src/index-objects.ts index c40e02331b..e5c03a9e96 100644 --- a/test/package/browser/template/src/index-objects.ts +++ b/test/package/browser/template/src/index-objects.ts @@ -23,16 +23,6 @@ type MyCustomObject = { counterKey: LiveCounter; }; -declare global { - export interface AblyObjectsTypes { - object: MyCustomObject; - } -} - -type ExplicitObjectType = { - someOtherKey: string; -}; - globalThis.testAblyPackage = async function () { const key = await createSandboxAblyAPIKey(); @@ -40,20 +30,20 @@ globalThis.testAblyPackage = async function () { const channel = realtime.channels.get('channel', { modes: ['OBJECT_SUBSCRIBE', 'OBJECT_PUBLISH'] }); await channel.attach(); - // check Objects can be accessed. - // expect entrypoint to be a PathObject for a LiveMap instance with Object type defined via the global AblyObjectsTypes interface. - // also checks that we can refer to the Objects types exported from 'ably'. - const myObject: Ably.PathObject> = await channel.object.get(); + // check Objects can be accessed on a channel with a custom type parameter. + // check that we can refer to the Objects types exported from 'ably' by referencing a LiveMap interface. + const myObject: Ably.PathObject> = await channel.object.get(); // check entrypoint has expected LiveMap TypeScript type methods const size: number | undefined = myObject.size(); - // check custom user provided typings via AblyObjectsTypes are working: + // check custom user provided typings work: + // primitives: const aNumber: number | undefined = myObject.get('numberKey').value(); const aString: string | undefined = myObject.get('stringKey').value(); const aBoolean: boolean | undefined = myObject.get('booleanKey').value(); const userProvidedUndefined: string | undefined = myObject.get('couldBeUndefined').value(); - // objects on the entrypoint: + // objects: const counter: Ably.LiveCounterPathObject = myObject.get('counterKey'); const map: Ably.LiveMapPathObject = myObject.get('mapKey'); // check string literal types works @@ -70,9 +60,4 @@ globalThis.testAblyPackage = async function () { const typedMessage: Ably.ObjectMessage | undefined = message; }); unsubscribe(); - - // check can provide custom types for the object.get() method, ignoring global AblyObjectsTypes interface - const explicitObjectType: Ably.PathObject> = - await channel.object.get(); - const someOtherKey: string | undefined = explicitObjectType.get('someOtherKey').value(); }; diff --git a/typedoc.json b/typedoc.json index bdbe58b4dc..24faf3bad9 100644 --- a/typedoc.json +++ b/typedoc.json @@ -20,6 +20,5 @@ "TypeAlias", "Variable", "Namespace" - ], - "intentionallyNotExported": ["__global.AblyObjectsTypes"] + ] } From 48cb02cb3cd1205e635d7a66210dbb44c8899430 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Wed, 3 Dec 2025 12:37:31 +0000 Subject: [PATCH 22/45] Fix using CompactedValue incorrectly resolves all primitives to `string` `Buffer` type is not defined in browser environments, and instead is resolved to `any`. This caused the conditional type `CompactedValue` to resolve `any` type to `string` apart from LiveObject types. One possible way to fix this is to use `ArrayBufferView` in the conditional type instead - it will cover Node's `Buffer` because `Buffer` extends `Uint8Array`, which is an `ArrayBufferView`. And `ArrayBufferView` is a JavaScript type so it works in both environments. Resolves AIT-45 --- ably.d.ts | 8 ++--- .../browser/template/src/index-objects.ts | 29 +++++++++++++++++-- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/ably.d.ts b/ably.d.ts index 6d1a388e76..56d69fcf80 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2447,13 +2447,13 @@ export type CompactedValue = : [T] extends [LiveCounter | undefined] ? number | undefined : // Binary types (converted to base64 strings) - [T] extends [Buffer] + [T] extends [ArrayBuffer] ? string - : [T] extends [Buffer | undefined] + : [T] extends [ArrayBuffer | undefined] ? string | undefined - : [T] extends [ArrayBuffer] + : [T] extends [ArrayBufferView] ? string - : [T] extends [ArrayBuffer | undefined] + : [T] extends [ArrayBufferView | undefined] ? string | undefined : // Other primitive types [T] extends [Primitive] diff --git a/test/package/browser/template/src/index-objects.ts b/test/package/browser/template/src/index-objects.ts index e5c03a9e96..b12503418c 100644 --- a/test/package/browser/template/src/index-objects.ts +++ b/test/package/browser/template/src/index-objects.ts @@ -1,5 +1,5 @@ import * as Ably from 'ably'; -import { LiveCounter, LiveMap } from 'ably'; +import { CompactedValue, LiveCounter, LiveMap } from 'ably'; import Objects from 'ably/objects'; import { createSandboxAblyAPIKey } from './sandbox'; @@ -21,6 +21,8 @@ type MyCustomObject = { }>; }>; counterKey: LiveCounter; + arrayBufferKey: ArrayBuffer; + bufferKey: Buffer; }; globalThis.testAblyPackage = async function () { @@ -45,7 +47,8 @@ globalThis.testAblyPackage = async function () { const userProvidedUndefined: string | undefined = myObject.get('couldBeUndefined').value(); // objects: const counter: Ably.LiveCounterPathObject = myObject.get('counterKey'); - const map: Ably.LiveMapPathObject = myObject.get('mapKey'); + const map: Ably.LiveMapPathObject ? T : never> = + myObject.get('mapKey'); // check string literal types works // need to use nullish coalescing as we didn't actually create any data on the entrypoint object, // so the next calls would fail. we only need to check that TypeScript types work @@ -60,4 +63,26 @@ globalThis.testAblyPackage = async function () { const typedMessage: Ably.ObjectMessage | undefined = message; }); unsubscribe(); + + // compact value + const compact: CompactedValue> | undefined = myObject.compact(); + const compactType: + | { + numberKey: number; + stringKey: string; + booleanKey: boolean; + couldBeUndefined?: string | undefined; + mapKey: { + foo: 'bar'; + nestedMap?: + | { + baz: 'qux'; + } + | undefined; + }; + counterKey: number; + arrayBufferKey: string; + bufferKey: string; + } + | undefined = compact; }; From 4bca506cf655d3004eeade37548d7ce0b4bf593a Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Fri, 5 Dec 2025 11:44:31 +0000 Subject: [PATCH 23/45] Handle cyclic references in PathObject/Instance .compact() methods Cyclic references currently can be created via REST API which supports setting key in a map with a reference to another object by its id. To handle this in realtime we memoize compacted entries for visited maps and return references to those memoized entries when encountering the same map again during compaction. Resolves AIT-29 --- ably.d.ts | 24 ++++++ src/plugins/objects/livemap.ts | 15 +++- test/realtime/objects.test.js | 138 +++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 2 deletions(-) diff --git a/ably.d.ts b/ably.d.ts index 6d1a388e76..819b28134c 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2606,6 +2606,9 @@ export interface LiveMapPathObject = Record = Record = Record extends InstanceBase, 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 diff --git a/src/plugins/objects/livemap.ts b/src/plugins/objects/livemap.ts index 1bf9ac4b6d..c52b8c7a30 100644 --- a/src/plugins/objects/livemap.ts +++ b/src/plugins/objects/livemap.ts @@ -492,17 +492,28 @@ export class LiveMap = Record> { + compact(memoizedObjects?: Map>): API.CompactedValue> { + const memo = memoizedObjects ?? new Map>(); const result: Record = {} as Record; + // 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; } diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js index 544b828402..d849f9977d 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/objects.test.js @@ -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) => { @@ -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) => { From 759e678f5bfd3400e074178a2da4ec1ddd3e0397 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Wed, 10 Dec 2025 04:51:53 +0000 Subject: [PATCH 24/45] Fix type inference in LiveMap.create to catch type errors Previously, TypeScript would not catch type errors when creating LiveMap instances and assigning them to a collection via LiveMap.set with incorrect data structures. For example, passing `{ done: 1 }` (number) when the expected type for the object at path required `{ done: boolean }` would not produce a compilation error. The issue was that TypeScript inferred the generic type parameter T from the `initialData` argument itself, rather than from the contextual type where the created object would be used. This meant TypeScript would infer T based on what was actually passed, effectively making any structure valid. Fixed by wrapping the `initialData` parameter type with `NoInfer`, which prevents TypeScript from inferring T from that parameter. Now T must be inferred from the calling context (e.g., the expected type in a LiveMap.set() call), ensuring that the data passed to .create() is validated against the expected type structure. --- objects.d.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/objects.d.ts b/objects.d.ts index 04044f4210..d116d2b6cb 100644 --- a/objects.d.ts +++ b/objects.d.ts @@ -10,6 +10,20 @@ import { import { BaseRealtime } from './modular'; /* eslint-enable no-unused-vars, @typescript-eslint/no-unused-vars */ +/** + * Blocks inferences to the contained type. + * Polyfill for TypeScript's `NoInfer` utility type introduced in TypeScript 5.4. + * + * This works by leveraging deferred conditional types - the compiler can't + * evaluate the conditional until it knows what T is, which prevents TypeScript + * from digging into the type to find inference candidates. + * + * See: + * - https://stackoverflow.com/questions/56687668 + * - https://www.typescriptlang.org/docs/handbook/utility-types.html#noinfertype + */ +type NoInfer = [T][T extends any ? 0 : never]; + /** * Static utilities related to LiveMap instances. */ @@ -23,7 +37,9 @@ export class LiveMap { * @experimental */ static create>( - initialEntries?: T, + // block TypeScript from inferring T from the initialEntries argument, so instead it is inferred + // from the contextual type in a LiveMap.set call + initialEntries?: NoInfer, ): LiveMapType ? T : {}>; } From bb9cf359e9dbd4fe5c9165613288abbc3dbafc99 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Wed, 17 Dec 2025 18:18:01 +0000 Subject: [PATCH 25/45] Change Instance.id() to Instance.id getter property to better align with other public interfaces Types like Connection and LocalDevice which represent some class with behavior still expose a unique identifier as property rather than a method. This commit align our LiveObjects API to this convention. --- ably.d.ts | 4 +-- src/plugins/objects/batchcontext.ts | 10 +++---- src/plugins/objects/instance.ts | 2 +- src/plugins/objects/rootbatchcontext.ts | 2 +- test/realtime/objects.test.js | 38 ++++++++++++------------- 5 files changed, 27 insertions(+), 29 deletions(-) diff --git a/ably.d.ts b/ably.d.ts index d3d9b9ac61..0bc0e40be2 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2798,7 +2798,7 @@ interface BatchContextBase { * * @experimental */ - id(): string | undefined; + readonly id: string | undefined; } /** @@ -3372,7 +3372,7 @@ interface InstanceBase { * * @experimental */ - id(): string | undefined; + readonly id: string | undefined; /** * Registers a listener that is called each time this instance is updated. diff --git a/src/plugins/objects/batchcontext.ts b/src/plugins/objects/batchcontext.ts index dee40c9a21..edf27b62a7 100644 --- a/src/plugins/objects/batchcontext.ts +++ b/src/plugins/objects/batchcontext.ts @@ -39,10 +39,10 @@ export class DefaultBatchContext implements AnyBatchContext { return this._instance.compact(); } - id(): string | undefined { + get id(): string | undefined { this._realtimeObject.throwIfInvalidAccessApiConfiguration(); this._throwIfClosed(); - return this._instance.id(); + return this._instance.id; } *entries>(): IterableIterator<[keyof T, BatchContext]> { @@ -81,7 +81,7 @@ export class DefaultBatchContext implements AnyBatchContext { throw new this._client.ErrorInfo('Cannot set a key on a non-LiveMap instance', 92007, 400); } this._rootContext.queueMessages(async () => - LiveMap.createMapSetMessage(this._realtimeObject, this._instance.id()!, key, value), + LiveMap.createMapSetMessage(this._realtimeObject, this._instance.id!, key, value), ); } @@ -92,7 +92,7 @@ export class DefaultBatchContext implements AnyBatchContext { throw new this._client.ErrorInfo('Cannot remove a key from a non-LiveMap instance', 92007, 400); } this._rootContext.queueMessages(async () => [ - LiveMap.createMapRemoveMessage(this._realtimeObject, this._instance.id()!, key), + LiveMap.createMapRemoveMessage(this._realtimeObject, this._instance.id!, key), ]); } @@ -103,7 +103,7 @@ export class DefaultBatchContext implements AnyBatchContext { throw new this._client.ErrorInfo('Cannot increment a non-LiveCounter instance', 92007, 400); } this._rootContext.queueMessages(async () => [ - LiveCounter.createCounterIncMessage(this._realtimeObject, this._instance.id()!, amount ?? 1), + LiveCounter.createCounterIncMessage(this._realtimeObject, this._instance.id!, amount ?? 1), ]); } diff --git a/src/plugins/objects/instance.ts b/src/plugins/objects/instance.ts index 97fbdcbec6..552afa89f2 100644 --- a/src/plugins/objects/instance.ts +++ b/src/plugins/objects/instance.ts @@ -34,7 +34,7 @@ export class DefaultInstance implements AnyInstance { this._client = this._realtimeObject.getClient(); } - id(): string | undefined { + get id(): string | undefined { if (!(this._value instanceof LiveObject)) { // no id exists for non-LiveObject types return undefined; diff --git a/src/plugins/objects/rootbatchcontext.ts b/src/plugins/objects/rootbatchcontext.ts index 66927e4462..9978be1def 100644 --- a/src/plugins/objects/rootbatchcontext.ts +++ b/src/plugins/objects/rootbatchcontext.ts @@ -51,7 +51,7 @@ export class RootBatchContext extends DefaultBatchContext { /** @internal */ wrapInstance(instance: Instance): DefaultBatchContext { - const objectId = instance.id(); + const objectId = instance.id; if (objectId) { // memoize liveobject instances by their object ids if (this._wrappedInstances.has(objectId)) { diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js index d849f9977d..4132bf4f7f 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/objects.test.js @@ -297,7 +297,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await channel.attach(); const entryPathObject = await channel.object.get(); - expect(entryPathObject.instance().id()).to.equal('root', 'root object should have an object id "root"'); + expect(entryPathObject.instance().id).to.equal('root', 'root object should have an object id "root"'); }, client); }); @@ -2110,8 +2110,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); await objectsCreatedPromise; - const mapId = entryInstance.get('map').id(); - const counterId = entryInstance.get('counter').id(); + const mapId = entryInstance.get('map').id; + const counterId = entryInstance.get('counter').id; const mapSubPromise = new Promise((resolve, reject) => entryInstance.get('map').subscribe((event) => { @@ -4846,7 +4846,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expect(event.message.operation).to.deep.include( { action: 'map.set', - objectId: entryPathObject.get('map').instance().id(), + objectId: entryPathObject.get('map').instance().id, }, 'Check event message operation', ); @@ -5185,7 +5185,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const instanceScenarios = [ { - description: 'DefaultInstance.id() returns object ID of the underlying LiveObject', + description: 'DefaultInstance.id returns object ID of the underlying LiveObject', action: async (ctx) => { const { objectsHelper, channelName, entryInstance } = ctx; @@ -5208,8 +5208,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const map = entryInstance.get('map'); const counter = entryInstance.get('counter'); - expect(map.id()).to.equal(mapId, 'Check DefaultInstance.id() for map matches expected value'); - expect(counter.id()).to.equal(counterId, 'Check DefaultInstance.id() for counter matches expected value'); + expect(map.id).to.equal(mapId, 'Check DefaultInstance.id for map matches expected value'); + expect(counter.id).to.equal(counterId, 'Check DefaultInstance.id for counter matches expected value'); }, }, @@ -5492,10 +5492,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function const primitiveInstance = mapInstance.get('foo'); // next methods silently handle incorrect underlying type - expect( - primitiveInstance.id(), - 'Check DefaultInstance.id() for wrong underlying object type returns undefined', - ).to.be.undefined; + expect(primitiveInstance.id, 'Check DefaultInstance.id for wrong underlying object type returns undefined') + .to.be.undefined; expect( primitiveInstance.get('foo'), 'Check DefaultInstance.get() for wrong underlying object type returns undefined', @@ -5776,7 +5774,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function try { expect(event.object, 'Check event object exists').to.exist; expectInstanceOf(event.object, 'DefaultInstance', 'Check event object is DefaultInstance'); - expect(event.object.id()).to.equal('root', 'Check event object has correct object ID'); + expect(event.object.id).to.equal('root', 'Check event object has correct object ID'); expect(event.object).to.equal(entryInstance, 'Check event object is the same instance'); resolve(); } catch (error) { @@ -5840,8 +5838,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expect(event.message, 'Check event message exists').to.exist; expect(event.message.operation).to.deep.include( events.length === 0 - ? { action: 'map.set', objectId: map.id(), mapOp: { key: 'foo', data: { value: 'bar' } } } - : { action: 'map.remove', objectId: map.id(), mapOp: { key: 'foo' } }, + ? { action: 'map.set', objectId: map.id, mapOp: { key: 'foo', data: { value: 'bar' } } } + : { action: 'map.remove', objectId: map.id, mapOp: { key: 'foo' } }, 'Check event message operation', ); events.push(event); @@ -5883,7 +5881,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expect(event.message.operation).to.deep.include( { action: 'counter.inc', - objectId: counter.id(), + objectId: counter.id, counterOp: { amount: events.length === 0 ? 1 : -2 }, }, 'Check event message operation', @@ -6291,7 +6289,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expect(event?.message?.operation).to.deep.include( { action: 'counter.inc', - objectId: counter.id(), + objectId: counter.id, counterOp: { amount: 1 }, }, 'Check counter subscription callback is called with an expected event message for COUNTER_INC operation', @@ -6332,7 +6330,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expect(event?.message?.operation).to.deep.include( { action: 'counter.inc', - objectId: counter.id(), + objectId: counter.id, counterOp: { amount: expectedInc }, }, `Check counter subscription callback is called with an expected event message operation for ${currentUpdateIndex + 1} times`, @@ -6376,7 +6374,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expect(event?.message?.operation).to.deep.include( { action: 'map.set', - objectId: map.id(), + objectId: map.id, mapOp: { key: 'stringKey', data: { value: 'stringValue' } }, }, 'Check map subscription callback is called with an expected event message for MAP_SET operation', @@ -6412,7 +6410,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function map.subscribe((event) => { try { expect(event?.message?.operation).to.deep.include( - { action: 'map.remove', objectId: map.id(), mapOp: { key: 'stringKey' } }, + { action: 'map.remove', objectId: map.id, mapOp: { key: 'stringKey' } }, 'Check map subscription callback is called with an expected event message for MAP_REMOVE operation', ); resolve(); @@ -7128,7 +7126,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expect(() => ctx.get()).to.throw(errorMsg); expect(() => ctx.value()).to.throw(errorMsg); expect(() => ctx.compact()).to.throw(errorMsg); - expect(() => ctx.id()).to.throw(errorMsg); + expect(() => ctx.id).to.throw(errorMsg); expect(() => [...ctx.entries()]).to.throw(errorMsg); expect(() => [...ctx.keys()]).to.throw(errorMsg); expect(() => [...ctx.values()]).to.throw(errorMsg); From 71ca6ef62c43d9dea61f0286148b7b685c8eb388 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Wed, 10 Dec 2025 03:00:23 +0000 Subject: [PATCH 26/45] Add RealtimeChannel.ensureAttached and use it in RealtimePresence Also will enable implicit attach in RealtimeObject in the following commit. --- src/common/lib/client/presencemap.ts | 6 +- src/common/lib/client/realtimechannel.ts | 25 ++++++++ src/common/lib/client/realtimepresence.ts | 74 ++++++----------------- 3 files changed, 48 insertions(+), 57 deletions(-) diff --git a/src/common/lib/client/presencemap.ts b/src/common/lib/client/presencemap.ts index 0cace6c59a..c8031746d4 100644 --- a/src/common/lib/client/presencemap.ts +++ b/src/common/lib/client/presencemap.ts @@ -176,7 +176,7 @@ export class PresenceMap extends EventEmitter { this.emit('sync'); } - waitSync(callback: () => void) { + async waitSync(): Promise { const syncInProgress = this.syncInProgress; Logger.logAction( this.logger, @@ -185,10 +185,10 @@ export class PresenceMap extends EventEmitter { 'channel = ' + this.presence.channel.name + '; syncInProgress = ' + syncInProgress, ); if (!syncInProgress) { - callback(); return; } - this.once('sync', callback); + + await this.once('sync'); } clear() { diff --git a/src/common/lib/client/realtimechannel.ts b/src/common/lib/client/realtimechannel.ts index 5b8f59f50d..70a8a604b0 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -1043,6 +1043,31 @@ class RealtimeChannel extends EventEmitter { const restMixin = this.client.rest.channelMixin; return restMixin.getMessageVersions(this, serialOrMessage, params); } + + /** + * Ensures the channel is attached, attaching if necessary. + * + * This method is intended for use by features like Presence or Objects that need to + * implicitly attach the channel when an operation is called (e.g., `presence.get()` per RTP11b, + * or `objects.get()`). This guarantees that the corresponding sync sequence will start and + * that the operation will resolve for callers even if they did not explicitly attach beforehand. + */ + async ensureAttached(): Promise { + switch (this.state) { + case 'attached': + case 'suspended': + break; + case 'initialized': + case 'detached': + case 'detaching': + case 'attaching': + await this.attach(); + break; + case 'failed': + default: + throw ErrorInfo.fromValues(this.invalidStateError()); + } + } } function omitAgent(channelParams?: API.ChannelParams) { diff --git a/src/common/lib/client/realtimepresence.ts b/src/common/lib/client/realtimepresence.ts index 1951083764..0c615e9a63 100644 --- a/src/common/lib/client/realtimepresence.ts +++ b/src/common/lib/client/realtimepresence.ts @@ -34,27 +34,6 @@ function isAnonymousOrWildcard(realtimePresence: RealtimePresence) { return (!clientId || clientId === '*') && realtime.connection.state === 'connected'; } -/* Callback is called only in the event of an error */ -function waitAttached(channel: RealtimeChannel, callback: ErrCallback, action: () => void) { - switch (channel.state) { - case 'attached': - case 'suspended': - action(); - break; - case 'initialized': - case 'detached': - case 'detaching': - case 'attaching': - Utils.whenPromiseSettles(channel.attach(), function (err: Error | null) { - if (err) callback(err); - else action(); - }); - break; - default: - callback(ErrorInfo.fromValues(channel.invalidStateError())); - } -} - class RealtimePresence extends EventEmitter { channel: RealtimeChannel; pendingPresence: { presence: WirePresenceMessage; callback: ErrCallback }[]; @@ -200,42 +179,29 @@ class RealtimePresence extends EventEmitter { async get(params?: RealtimePresenceParams): Promise { const waitForSync = !params || ('waitForSync' in params ? params.waitForSync : true); - return new Promise((resolve, reject) => { - function returnMembers(members: PresenceMap) { - resolve(params ? members.list(params) : members.values()); - } + function toMessages(members: PresenceMap): PresenceMessage[] { + return params ? members.list(params) : members.values(); + } - /* Special-case the suspended state: can still get (stale) presence set if waitForSync is false */ - if (this.channel.state === 'suspended') { - if (waitForSync) { - reject( - ErrorInfo.fromValues({ - statusCode: 400, - code: 91005, - message: 'Presence state is out of sync due to channel being in the SUSPENDED state', - }), - ); - } else { - returnMembers(this.members); - } - return; + /* Special-case the suspended state: can still get (stale) presence set if waitForSync is false */ + if (this.channel.state === 'suspended') { + if (waitForSync) { + throw ErrorInfo.fromValues({ + statusCode: 400, + code: 91005, + message: 'Presence state is out of sync due to channel being in the SUSPENDED state', + }); } + return toMessages(this.members); + } - waitAttached( - this.channel, - (err) => reject(err), - () => { - const members = this.members; - if (waitForSync) { - members.waitSync(function () { - returnMembers(members); - }); - } else { - returnMembers(members); - } - }, - ); - }); + await this.channel.ensureAttached(); + const members = this.members; + if (waitForSync) { + await members.waitSync(); + } + + return toMessages(this.members); } async history(params: RealtimeHistoryParams | null): Promise> { From 9bde15e262b44eb8caa726980c8b92fe04b70755 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Wed, 3 Dec 2025 11:57:14 +0000 Subject: [PATCH 27/45] Implicit attach on channel.object.get() Uses RealtimeChannel.ensureAttached() to keep the implementation consistent with presence. Resolves AIT-119 --- src/plugins/objects/realtimeobject.ts | 5 +++- test/realtime/objects.test.js | 33 +++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/plugins/objects/realtimeobject.ts b/src/plugins/objects/realtimeobject.ts index 0f3aa56741..75f53b99f5 100644 --- a/src/plugins/objects/realtimeobject.ts +++ b/src/plugins/objects/realtimeobject.ts @@ -80,7 +80,10 @@ export class RealtimeObject { * This is useful when working with multiple channels with different underlying data structure. */ async get>(): Promise>> { - this.throwIfInvalidAccessApiConfiguration(); // RTO1a, RTO1b + this._throwIfMissingChannelMode('object_subscribe'); + + // implicit attach before proceeding + await this._channel.ensureAttached(); // if we're not synced yet, wait for sync sequence to finish before returning root if (this._state !== ObjectsState.synced) { diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js index 4132bf4f7f..79541aa2e3 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/objects.test.js @@ -429,6 +429,34 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, client); }); + /** @nospec */ + it('RealtimeObject.get() on unattached channel implicitly attaches and waits for sync', async function () { + const helper = this.test.helper; + const client = RealtimeWithObjects(helper); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + const channel = client.channels.get('channel', channelOptionsWithObjects()); + expect(channel.state).to.equal('initialized', 'Channel should be in initialized state'); + + // Set up a timeout to catch if get() hangs + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('RealtimeObject.get() timed out')), 10000); + }); + + // Call get() on unattached channel - this should automatically attach and resolve + const getPromise = channel.object.get(); + + // Race between get() and timeout - get() should win by implicitly attaching and syncing state + const entryPathObject = await Promise.race([getPromise, timeoutPromise]); + + // Channel should now be attached, and root object returned + expect(channel.state).to.equal('attached', 'Channel should be attached after RealtimeObject.get() call'); + + expectInstanceOf(entryPathObject, 'DefaultPathObject', 'entrypoint should be of DefaultPathObject type'); + expect(entryPathObject.instance().id).to.equal('root', 'entrypoint should have an object id "root"'); + }, client); + }); + function checkKeyDataOnPathObject({ helper, key, keyData, pathObject, msg }) { if (keyData.data.bytes != null) { helper.recordPrivateApi('call.BufferUtils.base64Decode'); @@ -7089,8 +7117,6 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); const expectAccessApiToThrow = async ({ realtimeObject, map, counter, errorMsg }) => { - await expectToThrowAsync(async () => realtimeObject.get(), errorMsg); - expect(() => counter.value()).to.throw(errorMsg); expect(() => map.get('key')).to.throw(errorMsg); @@ -7156,6 +7182,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expectBatchContextWriteApiToThrow({ ctx, errorMsg: '"object_publish" channel mode' }); }); + await expectToThrowAsync(async () => realtimeObject.get(), '"object_subscribe" channel mode'); await expectAccessApiToThrow({ realtimeObject, map, counter, errorMsg: '"object_subscribe" channel mode' }); await expectWriteApiToThrow({ entryInstance, map, counter, errorMsg: '"object_publish" channel mode' }); }, @@ -7178,6 +7205,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expectBatchContextWriteApiToThrow({ ctx, errorMsg: '"object_publish" channel mode' }); }); + await expectToThrowAsync(async () => realtimeObject.get(), '"object_subscribe" channel mode'); await expectAccessApiToThrow({ realtimeObject, map, counter, errorMsg: '"object_subscribe" channel mode' }); await expectWriteApiToThrow({ entryInstance, map, counter, errorMsg: '"object_publish" channel mode' }); }, @@ -7228,6 +7256,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expectBatchContextWriteApiToThrow({ ctx, errorMsg: 'failed as channel state is failed' }); }); + await expectToThrowAsync(async () => realtimeObject.get(), 'failed as channel state is failed'); await expectAccessApiToThrow({ realtimeObject, map, From aa0431b3084eb509ea09b3f78f62a704c6443bfb Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Thu, 18 Dec 2025 11:25:43 +0000 Subject: [PATCH 28/45] Fix incorrect ObjectIdReference for CompactedJsonValue type Misses this in https://github.com/ably/ably-js/pull/2129 --- ably.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ably.d.ts b/ably.d.ts index 633f150a32..34a71bdfec 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2471,9 +2471,9 @@ export interface ObjectIdReference { export type CompactedJsonValue = // LiveMap types - note: cyclic references become ObjectIdReference [T] extends [LiveMap] - ? { [K in keyof U]: CompactedJsonValue | ObjectIdReference } + ? { [K in keyof U]: CompactedJsonValue } | ObjectIdReference : [T] extends [LiveMap | undefined] - ? { [K in keyof U]: CompactedJsonValue | ObjectIdReference } | undefined + ? { [K in keyof U]: CompactedJsonValue } | ObjectIdReference | undefined : // LiveCounter types [T] extends [LiveCounter] ? number From 3887fe9b3365e3aa4b2bc7071d179a68ad4784e8 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Wed, 17 Dec 2025 19:30:58 +0000 Subject: [PATCH 29/45] Split PathObject/Instance.compact() into compact() and compactJson() compact() now returns a memory-traversable object (binary is left as memory Buffers, cyclic references are in-memory pointers), and compactJson() returns a JSON-serializable object (binary is base64 strings, cyclic references are { objectId: string } references). This change addresses inconsistencies in current compact() behavior. --- ably.d.ts | 229 ++++++++++++- src/plugins/objects/batchcontext.ts | 16 +- src/plugins/objects/instance.ts | 26 +- src/plugins/objects/livemap.ts | 63 +++- src/plugins/objects/pathobject.ts | 38 ++- test/realtime/objects.test.js | 496 ++++++++++++++++++++++++++-- 6 files changed, 808 insertions(+), 60 deletions(-) diff --git a/ably.d.ts b/ably.d.ts index 0bc0e40be2..633f150a32 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2432,8 +2432,8 @@ export type LiveObject = LiveMap | LiveCounter; export type Value = LiveObject | Primitive; /** - * CompactedValue transforms LiveObject types into plain JavaScript equivalents. - * LiveMap becomes an object, LiveCounter becomes a number, binary values become base64-encoded strings, other primitives remain unchanged. + * CompactedValue transforms LiveObject types into in-memory JavaScript equivalents. + * LiveMap becomes an object, LiveCounter becomes a number, primitive values remain unchanged. */ export type CompactedValue = // LiveMap types @@ -2441,6 +2441,39 @@ export type CompactedValue = ? { [K in keyof U]: CompactedValue } : [T] extends [LiveMap | undefined] ? { [K in keyof U]: CompactedValue } | undefined + : // LiveCounter types + [T] extends [LiveCounter] + ? number + : [T] extends [LiveCounter | undefined] + ? number | undefined + : // Other primitive types + [T] extends [Primitive] + ? T + : [T] extends [Primitive | undefined] + ? T + : any; + +/** + * Represents a cyclic object reference in a JSON-serializable format. + */ +export interface ObjectIdReference { + /** The referenced object Id. */ + objectId: string; +} + +/** + * CompactedJsonValue transforms LiveObject types into JSON-serializable equivalents. + * LiveMap becomes an object, LiveCounter becomes a number, binary values become base64-encoded strings, + * other primitives remain unchanged. + * + * Additionally, cyclic references are represented as `{ objectId: string }` instead of in-memory pointers to same objects. + */ +export type CompactedJsonValue = + // LiveMap types - note: cyclic references become ObjectIdReference + [T] extends [LiveMap] + ? { [K in keyof U]: CompactedJsonValue | ObjectIdReference } + : [T] extends [LiveMap | undefined] + ? { [K in keyof U]: CompactedJsonValue | ObjectIdReference } | undefined : // LiveCounter types [T] extends [LiveCounter] ? number @@ -2604,17 +2637,32 @@ export interface LiveMapPathObject = Record | undefined; /** - * Get a JavaScript object representation of the map at this path. - * Binary values are returned as base64-encoded strings. + * Get an in-memory JavaScript object representation of the map at this path. * 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`. * + * Use {@link LiveMapPathObject.compactJson | compactJson()} for a JSON-serializable representation. + * * @experimental */ compact(): CompactedValue> | undefined; + + /** + * Get a JSON-serializable representation of the map at this path. + * Binary values are converted to base64-encoded strings. + * Cyclic references are represented as `{ objectId: string }` instead of in-memory pointers, + * making the result safe to pass to `JSON.stringify()`. + * + * If the path does not resolve to any specific instance, returns `undefined`. + * + * Use {@link LiveMapPathObject.compact | compact()} for an in-memory representation. + * + * @experimental + */ + compactJson(): CompactedJsonValue> | undefined; } /** @@ -2640,11 +2688,23 @@ export interface LiveCounterPathObject extends PathObjectBase, LiveCounterOperat /** * Get a number representation of the counter at this path. + * This is an alias for calling {@link LiveCounterPathObject.value | value()}. + * * If the path does not resolve to any specific instance, returns `undefined`. * * @experimental */ compact(): CompactedValue | undefined; + + /** + * Get a number representation of the counter at this path. + * This is an alias for calling {@link LiveCounterPathObject.value | value()}. + * + * If the path does not resolve to any specific instance, returns `undefined`. + * + * @experimental + */ + compactJson(): CompactedJsonValue | undefined; } /** @@ -2661,13 +2721,23 @@ export interface PrimitivePathObject extends Pa /** * Get a JavaScript object representation of the primitive value at this path. - * Binary values are returned as base64-encoded strings. + * This is an alias for calling {@link PrimitivePathObject.value | value()}. * * If the path does not resolve to any specific entry, returns `undefined`. * * @experimental */ compact(): CompactedValue | undefined; + + /** + * Get a JSON-serializable representation of the primitive value at this path. + * Binary values are converted to base64-encoded strings. + * + * If the path does not resolve to any specific entry, returns `undefined`. + * + * @experimental + */ + compactJson(): CompactedJsonValue | undefined; } /** @@ -2756,8 +2826,8 @@ export interface AnyPathObject instance(): Instance | undefined; /** - * Get a JavaScript object representation of the object at this path. - * Binary values are returned as base64-encoded strings. + * Get an in-memory JavaScript object representation of the object at this path. + * For primitive types, this is an alias for calling {@link AnyPathObject.value | value()}. * * When compacting a {@link LiveMap}, cyclic references are handled through * memoization, returning shared compacted object references for previously @@ -2766,9 +2836,26 @@ export interface AnyPathObject * * If the path does not resolve to any specific entry, returns `undefined`. * + * Use {@link AnyPathObject.compactJson | compactJson()} for a JSON-serializable representation. + * * @experimental */ compact(): CompactedValue | undefined; + + /** + * Get a JSON-serializable representation of the object at this path. + * Binary values are converted to base64-encoded strings. + * + * When compacting a {@link LiveMap}, cyclic references are represented as `{ objectId: string }` + * instead of in-memory pointers, making the result safe to pass to `JSON.stringify()`. + * + * If the path does not resolve to any specific entry, returns `undefined`. + * + * Use {@link AnyPathObject.compact | compact()} for an in-memory representation. + * + * @experimental + */ + compactJson(): CompactedJsonValue | undefined; } /** @@ -2865,17 +2952,32 @@ export interface LiveMapBatchContext = Record(key: K): BatchContext | undefined; /** - * Get a JavaScript object representation of the map instance. - * Binary values are returned as base64-encoded strings. + * Get an in-memory JavaScript object representation of the map instance. * 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`. * + * Use {@link LiveMapBatchContext.compactJson | compactJson()} for a JSON-serializable representation. + * * @experimental */ compact(): CompactedValue> | undefined; + + /** + * Get a JSON-serializable representation of the map instance. + * Binary values are converted to base64-encoded strings. + * Cyclic references are represented as `{ objectId: string }` instead of in-memory pointers, + * making the result safe to pass to `JSON.stringify()`. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * Use {@link LiveMapBatchContext.compact | compact()} for an in-memory representation. + * + * @experimental + */ + compactJson(): CompactedJsonValue> | undefined; } /** @@ -2892,11 +2994,23 @@ export interface LiveCounterBatchContext extends BatchContextBase, BatchContextL /** * Get a number representation of the counter instance. + * This is an alias for calling {@link LiveCounterBatchContext.value | value()}. + * * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. * * @experimental */ compact(): CompactedValue | undefined; + + /** + * Get a number representation of the counter instance. + * This is an alias for calling {@link LiveCounterBatchContext.value | value()}. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compactJson(): CompactedJsonValue | undefined; } /** @@ -2913,13 +3027,23 @@ export interface PrimitiveBatchContext { /** * Get a JavaScript object representation of the primitive value. - * Binary values are returned as base64-encoded strings. + * This is an alias for calling {@link PrimitiveBatchContext.value | value()}. * * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. * * @experimental */ compact(): CompactedValue | undefined; + + /** + * Get a JSON-serializable representation of the primitive value. + * Binary values are converted to base64-encoded strings. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compactJson(): CompactedJsonValue | undefined; } /** @@ -3003,8 +3127,8 @@ export interface AnyBatchContext extends BatchContextBase, AnyBatchContextCollec value(): T | undefined; /** - * Get a JavaScript object representation of the object instance. - * Binary values are returned as base64-encoded strings. + * Get an in-memory JavaScript object representation of the object instance. + * For primitive types, this is an alias for calling {@link AnyBatchContext.value | value()}. * * When compacting a {@link LiveMap}, cyclic references are handled through * memoization, returning shared compacted object references for previously @@ -3013,9 +3137,26 @@ export interface AnyBatchContext extends BatchContextBase, AnyBatchContextCollec * * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. * + * Use {@link AnyBatchContext.compactJson | compactJson()} for a JSON-serializable representation. + * * @experimental */ compact(): CompactedValue | undefined; + + /** + * Get a JSON-serializable representation of the object instance. + * Binary values are converted to base64-encoded strings. + * + * When compacting a {@link LiveMap}, cyclic references are represented as `{ objectId: string }` + * instead of in-memory pointers, making the result safe to pass to `JSON.stringify()`. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * Use {@link AnyBatchContext.compact | compact()} for an in-memory representation. + * + * @experimental + */ + compactJson(): CompactedJsonValue | undefined; } /** @@ -3477,17 +3618,32 @@ export interface LiveMapInstance = Record(key: K): Instance | undefined; /** - * Get a JavaScript object representation of the map instance. - * Binary values are returned as base64-encoded strings. + * Get an in-memory JavaScript object representation of the map instance. * 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`. * + * Use {@link LiveMapInstance.compactJson | compactJson()} for a JSON-serializable representation. + * * @experimental */ compact(): CompactedValue> | undefined; + + /** + * Get a JSON-serializable representation of the map instance. + * Binary values are converted to base64-encoded strings. + * Cyclic references are represented as `{ objectId: string }` instead of in-memory pointers, + * making the result safe to pass to `JSON.stringify()`. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * Use {@link LiveMapInstance.compact | compact()} for an in-memory representation. + * + * @experimental + */ + compactJson(): CompactedJsonValue> | undefined; } /** @@ -3504,11 +3660,23 @@ export interface LiveCounterInstance extends InstanceBase, LiveCoun /** * Get a number representation of the counter instance. + * This is an alias for calling {@link LiveCounterInstance.value | value()}. + * * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. * * @experimental */ compact(): CompactedValue | undefined; + + /** + * Get a number representation of the counter instance. + * This is an alias for calling {@link LiveCounterInstance.value | value()}. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compactJson(): CompactedJsonValue | undefined; } /** @@ -3528,13 +3696,23 @@ export interface PrimitiveInstance { /** * Get a JavaScript object representation of the primitive value. - * Binary values are returned as base64-encoded strings. + * This is an alias for calling {@link PrimitiveInstance.value | value()}. * * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. * * @experimental */ compact(): CompactedValue | undefined; + + /** + * Get a JSON-serializable representation of the primitive value. + * Binary values are converted to base64-encoded strings. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compactJson(): CompactedJsonValue | undefined; } /** @@ -3620,8 +3798,8 @@ export interface AnyInstance extends InstanceBase, AnyInstan value(): T | undefined; /** - * Get a JavaScript object representation of the object instance. - * Binary values are returned as base64-encoded strings. + * Get an in-memory JavaScript object representation of the object instance. + * For primitive types, this is an alias for calling {@link AnyInstance.value | value()}. * * When compacting a {@link LiveMap}, cyclic references are handled through * memoization, returning shared compacted object references for previously @@ -3630,9 +3808,26 @@ export interface AnyInstance extends InstanceBase, AnyInstan * * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. * + * Use {@link AnyInstance.compactJson | compactJson()} for a JSON-serializable representation. + * * @experimental */ compact(): CompactedValue | undefined; + + /** + * Get a JSON-serializable representation of the object instance. + * Binary values are converted to base64-encoded strings. + * + * When compacting a {@link LiveMap}, cyclic references are represented as `{ objectId: string }` + * instead of in-memory pointers, making the result safe to pass to `JSON.stringify()`. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * Use {@link AnyInstance.compact | compact()} for an in-memory representation. + * + * @experimental + */ + compactJson(): CompactedJsonValue | undefined; } /** diff --git a/src/plugins/objects/batchcontext.ts b/src/plugins/objects/batchcontext.ts index edf27b62a7..912259ac7e 100644 --- a/src/plugins/objects/batchcontext.ts +++ b/src/plugins/objects/batchcontext.ts @@ -1,5 +1,13 @@ import type BaseClient from 'common/lib/client/baseclient'; -import type { AnyBatchContext, BatchContext, CompactedValue, Instance, Primitive, Value } from '../../../ably'; +import type { + AnyBatchContext, + BatchContext, + CompactedJsonValue, + CompactedValue, + Instance, + Primitive, + Value, +} from '../../../ably'; import { DefaultInstance } from './instance'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; @@ -39,6 +47,12 @@ export class DefaultBatchContext implements AnyBatchContext { return this._instance.compact(); } + compactJson(): CompactedJsonValue | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + this._throwIfClosed(); + return this._instance.compactJson(); + } + get id(): string | undefined { this._realtimeObject.throwIfInvalidAccessApiConfiguration(); this._throwIfClosed(); diff --git a/src/plugins/objects/instance.ts b/src/plugins/objects/instance.ts index 552afa89f2..fb98b98672 100644 --- a/src/plugins/objects/instance.ts +++ b/src/plugins/objects/instance.ts @@ -3,6 +3,7 @@ import type { AnyInstance, BatchContext, BatchFunction, + CompactedJsonValue, CompactedValue, EventCallback, Instance, @@ -42,18 +43,39 @@ export class DefaultInstance implements AnyInstance { return this._value.getObjectId(); } + /** + * Returns an in-memory JavaScript object representation of this instance. + * Buffers are returned as-is. + * For primitive types, this is an alias for calling value(). + * + * Use compactJson() for a JSON-serializable representation. + */ compact(): CompactedValue | undefined { if (this._value instanceof LiveMap) { return this._value.compact() as CompactedValue; } + return this.value() as CompactedValue; + } + + /** + * Returns a JSON-serializable representation of this instance. + * Buffers are converted to base64 strings. + * + * Use compact() for an in-memory representation. + */ + compactJson(): CompactedJsonValue | undefined { + if (this._value instanceof LiveMap) { + return this._value.compactJson() as CompactedJsonValue; + } + const value = this.value(); if (this._client.Platform.BufferUtils.isBuffer(value)) { - return this._client.Platform.BufferUtils.base64Encode(value) as CompactedValue; + return this._client.Platform.BufferUtils.base64Encode(value) as CompactedJsonValue; } - return value as CompactedValue; + return value as CompactedJsonValue; } get(key: string): Instance | undefined { diff --git a/src/plugins/objects/livemap.ts b/src/plugins/objects/livemap.ts index c52b8c7a30..0a075c7b16 100644 --- a/src/plugins/objects/livemap.ts +++ b/src/plugins/objects/livemap.ts @@ -490,29 +490,72 @@ export class LiveMap = Record>): API.CompactedValue> { - const memo = memoizedObjects ?? new Map>(); + compact(visitedObjects?: Map>): API.CompactedValue> { + const visited = visitedObjects ?? new Map>(); const result: Record = {} as Record; // Memoize the compacted result to handle circular references - memo.set(this.getObjectId(), result); + visited.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) { + if (visited.has(value.getObjectId())) { + // If the LiveMap has already been visited, just reference it to avoid infinite loops + result[key] = visited.get(value.getObjectId()); + } else { + // Otherwise, compact it + result[key] = value.compact(visited); + } + continue; + } + + if (value instanceof LiveCounter) { + result[key] = value.value(); + continue; + } + + // other values are returned as-is + result[key] = value; + } + + return result; + } + + /** + * Returns a JSON-serializable representation of this LiveMap. + * LiveMap values are recursively compacted using their own compactJson methods. + * Cyclic references are represented as `{ objectId: string }` instead of in-memory pointers. + * Buffers are converted to base64 strings. + * + * Use compact() for an in-memory representation. + * + * @internal + */ + compactJson(visitedObjectIds?: Set): API.CompactedJsonValue> { + const visited = visitedObjectIds ?? new Set(); + const result: Record = {} as Record; + + // Mark this object ID as visited to handle circular references + visited.add(this.getObjectId()); // Use public entries() method to ensure we only include publicly exposed properties for (const [key, value] of this.entries()) { if (value instanceof LiveMap) { - 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()); + if (visited.has(value.getObjectId())) { + // If the LiveMap has already been visited, return its objectId to avoid infinite loops + result[key] = { objectId: value.getObjectId() }; } else { // Otherwise, compact it - result[key] = value.compact(memo); + result[key] = value.compactJson(visited); } continue; } diff --git a/src/plugins/objects/pathobject.ts b/src/plugins/objects/pathobject.ts index 9f7fc2b831..d988f82e94 100644 --- a/src/plugins/objects/pathobject.ts +++ b/src/plugins/objects/pathobject.ts @@ -3,6 +3,7 @@ import type { AnyPathObject, BatchContext, BatchFunction, + CompactedJsonValue, CompactedValue, EventCallback, Instance, @@ -51,9 +52,12 @@ export class DefaultPathObject implements AnyPathObject { } /** - * Returns a JavaScript object representation of the object at this path + * Returns an in-memory JavaScript object representation of the object at this path. * If the path does not resolve to any specific entry, returns `undefined`. - * Buffers are converted to base64 strings. + * Buffers are returned as-is. + * For primitive types, this is an alias for calling value(). + * + * Use compactJson() for a JSON-serializable representation. */ compact(): CompactedValue | undefined { try { @@ -63,13 +67,39 @@ export class DefaultPathObject implements AnyPathObject { return resolved.compact() as CompactedValue; } + return this.value() as CompactedValue; + } catch (error) { + if (this._client.Utils.isErrorInfoOrPartialErrorInfo(error) && error.code === 92005) { + // ignore path resolution errors and return undefined + return undefined; + } + // rethrow everything else + throw error; + } + } + + /** + * Returns a JSON-serializable representation of the object at this path. + * If the path does not resolve to any specific entry, returns `undefined`. + * Buffers are converted to base64 strings. + * + * Use compact() for an in-memory representation. + */ + compactJson(): CompactedJsonValue | undefined { + try { + const resolved = this._resolvePath(this._path); + + if (resolved instanceof LiveMap) { + return resolved.compactJson() as CompactedJsonValue; + } + const value = this.value(); if (this._client.Platform.BufferUtils.isBuffer(value)) { - return this._client.Platform.BufferUtils.base64Encode(value) as CompactedValue; + return this._client.Platform.BufferUtils.base64Encode(value) as CompactedJsonValue; } - return value as CompactedValue; + return value as CompactedJsonValue; } catch (error) { if (this._client.Utils.isErrorInfoOrPartialErrorInfo(error) && error.code === 92005) { // ignore path resolution errors and return undefined diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js index 4132bf4f7f..324586704c 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/objects.test.js @@ -4090,6 +4090,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // Next operations should not throw and silently handle non-existent path expect(nonExistentPathObj.compact(), 'Check PathObject.compact() for non-existent path returns undefined') .to.be.undefined; + expect( + nonExistentPathObj.compactJson(), + 'Check PathObject.compactJson() for non-existent path returns undefined', + ).to.be.undefined; expect(nonExistentPathObj.value(), 'Check PathObject.value() for non-existent path returns undefined').to.be .undefined; expect(nonExistentPathObj.instance(), 'Check PathObject.instance() for non-existent path returns undefined') @@ -4141,6 +4145,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // Next operations should not throw and silently handle incorrect path expect(wrongTypePathObj.compact(), 'Check PathObject.compact() for non-collection path returns undefined') .to.be.undefined; + expect( + wrongTypePathObj.compactJson(), + 'Check PathObject.compactJson() for non-collection path returns undefined', + ).to.be.undefined; expect(wrongTypePathObj.value(), 'Check PathObject.value() for non-collection path returns undefined').to.be .undefined; expect(wrongTypePathObj.instance(), 'Check PathObject.instance() for non-collection path returns undefined') @@ -4969,7 +4977,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, { - description: 'PathObject.compact() returns correct representation for primitive values', + description: 'PathObject.compact() returns value as is for primitive values', action: async (ctx) => { const { entryPathObject, entryInstance, helper } = ctx; @@ -4996,12 +5004,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function primitiveKeyData.forEach((keyData) => { const pathObj = entryPathObject.get(keyData.key); const compactValue = pathObj.compact(); - // expect buffer values to be base64-encoded strings - helper.recordPrivateApi('call.BufferUtils.isBuffer'); - helper.recordPrivateApi('call.BufferUtils.base64Encode'); - const expectedValue = BufferUtils.isBuffer(pathObj.value()) - ? BufferUtils.base64Encode(pathObj.value()) - : pathObj.value(); + const expectedValue = pathObj.value(); expect(compactValue).to.deep.equal( expectedValue, @@ -5026,13 +5029,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, { - description: 'PathObject.compact() returns plain object for LiveMap objects', + description: 'PathObject.compact() returns plain object for LiveMap objects with buffers as-is', action: async (ctx) => { const { entryInstance, entryPathObject, helper } = ctx; // Create nested structure with different value types const keysUpdatedPromise = Promise.all([waitForMapKeyUpdate(entryInstance, 'nestedMap')]); helper.recordPrivateApi('call.BufferUtils.utf8Encode'); + const bufferValue = BufferUtils.utf8Encode('value'); await entryPathObject.set( 'nestedMap', LiveMap.create({ @@ -5042,14 +5046,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function counterKey: LiveCounter.create(99), array: [1, 2, 3], obj: { nested: 'value' }, - buffer: BufferUtils.utf8Encode('value'), + buffer: bufferValue, }), ); await keysUpdatedPromise; const compactValue = entryPathObject.get('nestedMap').compact(); - helper.recordPrivateApi('call.BufferUtils.utf8Encode'); - helper.recordPrivateApi('call.BufferUtils.base64Encode'); const expected = { stringKey: 'stringValue', numberKey: 123, @@ -5057,7 +5059,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function counterKey: 99, array: [1, 2, 3], obj: { nested: 'value' }, - buffer: BufferUtils.base64Encode(BufferUtils.utf8Encode('value')), + buffer: bufferValue, }; expect(compactValue).to.deep.equal(expected, 'Check compact object has expected value'); @@ -5107,7 +5109,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function 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) + // root -> map1 -> map2 -> map1 pointer (back reference) const { objectId: map1Id } = await objectsHelper.operationRequest( channelName, @@ -5170,6 +5172,211 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, }, + { + description: 'PathObject.compactJson() returns JSON-encodable value for primitive values', + action: async (ctx) => { + const { entryPathObject, entryInstance, helper } = ctx; + + const keysUpdatedPromise = Promise.all( + primitiveKeyData.map((x) => waitForMapKeyUpdate(entryInstance, x.key)), + ); + await Promise.all( + primitiveKeyData.map(async (keyData) => { + let value; + if (keyData.data.bytes != null) { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + value = BufferUtils.base64Decode(keyData.data.bytes); + } else if (keyData.data.json != null) { + value = JSON.parse(keyData.data.json); + } else { + value = keyData.data.number ?? keyData.data.string ?? keyData.data.boolean; + } + + await entryPathObject.set(keyData.key, value); + }), + ); + await keysUpdatedPromise; + + primitiveKeyData.forEach((keyData) => { + const pathObj = entryPathObject.get(keyData.key); + const compactJsonValue = pathObj.compactJson(); + // expect buffer values to be base64-encoded strings + helper.recordPrivateApi('call.BufferUtils.isBuffer'); + helper.recordPrivateApi('call.BufferUtils.base64Encode'); + const expectedValue = BufferUtils.isBuffer(pathObj.value()) + ? BufferUtils.base64Encode(pathObj.value()) + : pathObj.value(); + + expect(compactJsonValue).to.deep.equal( + expectedValue, + `Check PathObject.compactJson() returns correct value for primitive "${keyData.key}"`, + ); + }); + }, + }, + + { + description: 'PathObject.compactJson() returns number for LiveCounter objects', + action: async (ctx) => { + const { entryInstance, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); + await entryPathObject.set('counter', LiveCounter.create(42)); + await keyUpdatedPromise; + + const compactJsonValue = entryPathObject.get('counter').compactJson(); + expect(compactJsonValue).to.equal(42, 'Check PathObject.compactJson() returns number for LiveCounter'); + }, + }, + + { + description: 'PathObject.compactJson() returns plain object for LiveMap with base64-encoded buffers', + action: async (ctx) => { + const { entryInstance, entryPathObject, helper } = ctx; + + // Create nested structure with different value types + const keysUpdatedPromise = Promise.all([waitForMapKeyUpdate(entryInstance, 'nestedMap')]); + helper.recordPrivateApi('call.BufferUtils.utf8Encode'); + await entryPathObject.set( + 'nestedMap', + LiveMap.create({ + stringKey: 'stringValue', + numberKey: 123, + booleanKey: true, + counterKey: LiveCounter.create(99), + array: [1, 2, 3], + obj: { nested: 'value' }, + buffer: BufferUtils.utf8Encode('value'), + }), + ); + await keysUpdatedPromise; + + const compactJsonValue = entryPathObject.get('nestedMap').compactJson(); + helper.recordPrivateApi('call.BufferUtils.utf8Encode'); + helper.recordPrivateApi('call.BufferUtils.base64Encode'); + const expected = { + stringKey: 'stringValue', + numberKey: 123, + booleanKey: true, + counterKey: 99, + array: [1, 2, 3], + obj: { nested: 'value' }, + buffer: BufferUtils.base64Encode(BufferUtils.utf8Encode('value')), + }; + + expect(compactJsonValue).to.deep.equal(expected, 'Check compact object has expected value'); + }, + }, + + { + description: 'PathObject.compactJson() handles complex nested structures', + action: async (ctx) => { + const { entryInstance, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'complex'); + await entryPathObject.set( + 'complex', + LiveMap.create({ + level1: LiveMap.create({ + level2: LiveMap.create({ + counter: LiveCounter.create(10), + primitive: 'deep value', + }), + directCounter: LiveCounter.create(20), + }), + topLevelCounter: LiveCounter.create(30), + }), + ); + await keyUpdatedPromise; + + const compactJsonValue = entryPathObject.get('complex').compactJson(); + const expected = { + level1: { + level2: { + counter: 10, + primitive: 'deep value', + }, + directCounter: 20, + }, + topLevelCounter: 30, + }; + + expect(compactJsonValue).to.deep.equal(expected, 'Check complex nested structure is compacted correctly'); + }, + }, + + { + description: 'PathObject.compactJson() handles cyclic references with objectId', + 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 -> map1BackRef = { objectId: map1Id } + + 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 compactJson() handles cyclic references by returning objectId + const compactJsonEntry = entryPathObject.compactJson(); + + expect(compactJsonEntry).to.exist; + expect(compactJsonEntry.map1).to.exist; + expect(compactJsonEntry.map1.foo).to.equal('bar', 'Check primitive value is preserved'); + expect(compactJsonEntry.map1.map2).to.exist; + expect(compactJsonEntry.map1.map2.baz).to.equal(42, 'Check nested primitive value is preserved'); + expect(compactJsonEntry.map1.map2.map1BackRef).to.exist; + + // The back reference should be { objectId: string } instead of in-memory pointer + expect(compactJsonEntry.map1.map2.map1BackRef).to.deep.equal( + { objectId: map1Id }, + 'Check cyclic reference returns objectId structure for JSON serialization', + ); + + // Verify the result can be JSON stringified (no circular reference error) + expect(() => JSON.stringify(compactJsonEntry)).to.not.throw(); + }, + }, + { description: 'PathObject.batch() passes RootBatchContext to its batch function', action: async (ctx) => { @@ -5992,7 +6199,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, { - description: 'DefaultInstance.compact() returns correct representation for primitive values', + description: 'DefaultInstance.compact() returns value as is for primitive values', action: async (ctx) => { const { entryInstance, entryPathObject, helper } = ctx; @@ -6019,12 +6226,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function primitiveKeyData.forEach((keyData) => { const instance = entryInstance.get(keyData.key); const compactValue = instance.compact(); - // expect buffer values to be base64-encoded strings - helper.recordPrivateApi('call.BufferUtils.isBuffer'); - helper.recordPrivateApi('call.BufferUtils.base64Encode'); - const expectedValue = BufferUtils.isBuffer(instance.value()) - ? BufferUtils.base64Encode(instance.value()) - : instance.value(); + const expectedValue = instance.value(); expect(compactValue).to.deep.equal( expectedValue, @@ -6049,13 +6251,14 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, { - description: 'DefaultInstance.compact() returns plain object for LiveMap objects', + description: 'DefaultInstance.compact() returns plain object for LiveMap objects with buffers as-is', action: async (ctx) => { const { entryInstance, entryPathObject, helper } = ctx; // Create nested structure with different value types const keysUpdatedPromise = Promise.all([waitForMapKeyUpdate(entryInstance, 'nestedMap')]); helper.recordPrivateApi('call.BufferUtils.utf8Encode'); + const bufferValue = BufferUtils.utf8Encode('value'); await entryPathObject.set( 'nestedMap', LiveMap.create({ @@ -6065,14 +6268,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function counterKey: LiveCounter.create(111), array: [1, 2, 3], obj: { nested: 'value' }, - buffer: BufferUtils.utf8Encode('value'), + buffer: bufferValue, }), ); await keysUpdatedPromise; const compactValue = entryInstance.get('nestedMap').compact(); - helper.recordPrivateApi('call.BufferUtils.utf8Encode'); - helper.recordPrivateApi('call.BufferUtils.base64Encode'); const expected = { stringKey: 'stringValue', numberKey: 456, @@ -6080,7 +6281,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function counterKey: 111, array: [1, 2, 3], obj: { nested: 'value' }, - buffer: BufferUtils.base64Encode(BufferUtils.utf8Encode('value')), + buffer: bufferValue, }; expect(compactValue).to.deep.equal(expected, 'Check compact object has expected value'); @@ -6161,7 +6362,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function 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) + // root -> map1 -> map2 -> map1 pointer (back reference) const { objectId: map1Id } = await objectsHelper.operationRequest( channelName, @@ -6224,6 +6425,248 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, }, + { + description: 'DefaultInstance.compactJson() returns JSON-encodable value for primitive values', + action: async (ctx) => { + const { entryInstance, entryPathObject, helper } = ctx; + + const keysUpdatedPromise = Promise.all( + primitiveKeyData.map((x) => waitForMapKeyUpdate(entryInstance, x.key)), + ); + await Promise.all( + primitiveKeyData.map(async (keyData) => { + let value; + if (keyData.data.bytes != null) { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + value = BufferUtils.base64Decode(keyData.data.bytes); + } else if (keyData.data.json != null) { + value = JSON.parse(keyData.data.json); + } else { + value = keyData.data.number ?? keyData.data.string ?? keyData.data.boolean; + } + + await entryPathObject.set(keyData.key, value); + }), + ); + await keysUpdatedPromise; + + primitiveKeyData.forEach((keyData) => { + const instance = entryInstance.get(keyData.key); + const compactJsonValue = instance.compactJson(); + // expect buffer values to be base64-encoded strings + helper.recordPrivateApi('call.BufferUtils.isBuffer'); + helper.recordPrivateApi('call.BufferUtils.base64Encode'); + const expectedValue = BufferUtils.isBuffer(instance.value()) + ? BufferUtils.base64Encode(instance.value()) + : instance.value(); + + expect(compactJsonValue).to.deep.equal( + expectedValue, + `Check DefaultInstance.compactJson() returns correct value for primitive "${keyData.key}"`, + ); + }); + }, + }, + + { + description: 'DefaultInstance.compactJson() returns number for LiveCounter objects', + action: async (ctx) => { + const { entryInstance, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); + await entryPathObject.set('counter', LiveCounter.create(42)); + await keyUpdatedPromise; + + const compactJsonValue = entryInstance.get('counter').compactJson(); + expect(compactJsonValue).to.equal(42, 'Check DefaultInstance.compactJson() returns number for LiveCounter'); + }, + }, + + { + description: 'DefaultInstance.compactJson() returns plain object for LiveMap with base64-encoded buffers', + action: async (ctx) => { + const { entryInstance, entryPathObject, helper } = ctx; + + // Create nested structure with different value types + const keysUpdatedPromise = Promise.all([waitForMapKeyUpdate(entryInstance, 'nestedMapInstance')]); + helper.recordPrivateApi('call.BufferUtils.utf8Encode'); + await entryPathObject.set( + 'nestedMapInstance', + LiveMap.create({ + stringKey: 'stringValue', + numberKey: 456, + booleanKey: false, + counterKey: LiveCounter.create(111), + array: [1, 2, 3], + obj: { nested: 'value' }, + buffer: BufferUtils.utf8Encode('value'), + }), + ); + await keysUpdatedPromise; + + const compactJsonValue = entryInstance.get('nestedMapInstance').compactJson(); + helper.recordPrivateApi('call.BufferUtils.utf8Encode'); + helper.recordPrivateApi('call.BufferUtils.base64Encode'); + const expected = { + stringKey: 'stringValue', + numberKey: 456, + booleanKey: false, + counterKey: 111, + array: [1, 2, 3], + obj: { nested: 'value' }, + buffer: BufferUtils.base64Encode(BufferUtils.utf8Encode('value')), + }; + + expect(compactJsonValue).to.deep.equal(expected, 'Check compact object has expected value'); + }, + }, + + { + description: 'DefaultInstance.compactJson() handles complex nested structures', + action: async (ctx) => { + const { entryInstance, entryPathObject } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'complex'); + await entryPathObject.set( + 'complex', + LiveMap.create({ + level1: LiveMap.create({ + level2: LiveMap.create({ + counter: LiveCounter.create(100), + primitive: 'instance deep value', + }), + directCounter: LiveCounter.create(200), + }), + topLevelCounter: LiveCounter.create(300), + }), + ); + await keyUpdatedPromise; + + const compactJsonValue = entryInstance.get('complex').compactJson(); + const expected = { + level1: { + level2: { + counter: 100, + primitive: 'instance deep value', + }, + directCounter: 200, + }, + topLevelCounter: 300, + }; + + expect(compactJsonValue).to.deep.equal(expected, 'Check complex nested structure is compacted correctly'); + }, + }, + + { + description: 'DefaultInstance.compactJson() and PathObject.compactJson() return equivalent results', + action: async (ctx) => { + const { entryInstance, entryPathObject, helper } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'comparison'); + helper.recordPrivateApi('call.BufferUtils.utf8Encode'); + await entryPathObject.set( + 'comparison', + LiveMap.create({ + counter: LiveCounter.create(50), + nested: LiveMap.create({ + value: 'test', + innerCounter: LiveCounter.create(25), + }), + primitive: 'comparison test', + buffer: BufferUtils.utf8Encode('value'), + }), + ); + await keyUpdatedPromise; + + const pathCompactJson = entryPathObject.get('comparison').compactJson(); + const instanceCompactJson = entryInstance.get('comparison').compactJson(); + + expect(pathCompactJson).to.deep.equal( + instanceCompactJson, + 'Check PathObject.compactJson() and DefaultInstance.compactJson() return equivalent results', + ); + }, + }, + + { + description: 'DefaultInstance.compactJson() handles cyclic references with objectId', + 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 -> map1BackRef = { objectId: map1Id } + + 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, 'map1Instance'); + await objectsHelper.operationRequest( + channelName, + objectsHelper.mapSetRestOp({ + objectId: 'root', + key: 'map1Instance', + value: { objectId: map1Id }, + }), + ); + await keyUpdatedPromise; + + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance.get('map1Instance'), 'map2Instance'); + await objectsHelper.operationRequest( + channelName, + objectsHelper.mapSetRestOp({ + objectId: map1Id, + key: 'map2Instance', + value: { objectId: map2Id }, + }), + ); + await keyUpdatedPromise; + + keyUpdatedPromise = waitForMapKeyUpdate( + entryInstance.get('map1Instance').get('map2Instance'), + 'map1BackRefInstance', + ); + await objectsHelper.operationRequest( + channelName, + objectsHelper.mapSetRestOp({ + objectId: map2Id, + key: 'map1BackRefInstance', + value: { objectId: map1Id }, + }), + ); + await keyUpdatedPromise; + + // Test that compactJson() handles cyclic references with objectId structure + const compactJsonEntry = entryInstance.compactJson(); + + expect(compactJsonEntry).to.exist; + expect(compactJsonEntry.map1Instance).to.exist; + expect(compactJsonEntry.map1Instance.foo).to.equal('bar', 'Check primitive value is preserved'); + expect(compactJsonEntry.map1Instance.map2Instance).to.exist; + expect(compactJsonEntry.map1Instance.map2Instance.baz).to.equal( + 42, + 'Check nested primitive value is preserved', + ); + expect(compactJsonEntry.map1Instance.map2Instance.map1BackRefInstance).to.exist; + + // The back reference should be { objectId: string } instead of in-memory pointer + expect(compactJsonEntry.map1Instance.map2Instance.map1BackRefInstance).to.deep.equal( + { objectId: map1Id }, + 'Check cyclic reference returns objectId structure for JSON serialization', + ); + + // Verify the result can be JSON stringified (no circular reference error) + expect(() => JSON.stringify(compactJsonEntry)).to.not.throw(); + }, + }, + { description: 'DefaultInstance.batch() passes RootBatchContext to its batch function', action: async (ctx) => { @@ -7126,6 +7569,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function expect(() => ctx.get()).to.throw(errorMsg); expect(() => ctx.value()).to.throw(errorMsg); expect(() => ctx.compact()).to.throw(errorMsg); + expect(() => ctx.compactJson()).to.throw(errorMsg); expect(() => ctx.id).to.throw(errorMsg); expect(() => [...ctx.entries()]).to.throw(errorMsg); expect(() => [...ctx.keys()]).to.throw(errorMsg); From 0f55261e3e9b91299e2a1e41f3c557f7f1ade1e2 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Thu, 18 Dec 2025 08:39:56 +0000 Subject: [PATCH 30/45] Enable named exports from 'ably/objects' for ESM and CJS consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Objects is now a named export instead of default export. Before: import Objects from 'ably/objects'; After: import { Objects, LiveCounter, LiveMap } from 'ably/objects'; Problem ------- Importing named exports from 'ably/objects' failed with: SyntaxError: The requested module 'ably/objects' does not provide an export named 'LiveCounter' This occurred because: 1. The package only had a single UMD bundle (objects.js) 2. Type declarations used `export = Objects` (CJS-style) 3. UMD bundles don't preserve ES module named exports Solutions Considered -------------------- 1. Default export only (status quo) - Downside: Users can't destructure utilities alongside the plugin - Usage: import Objects from 'ably/objects'; const { LiveMap } = Objects; 2. Namespace import pattern - Usage: import * as ObjectsPlugin from 'ably/objects'; - Downside: Non-idiomatic, verbose, confusing for users 3. Named exports with dual CJS/ESM builds (chosen) - Usage: import { Objects, LiveCounter, LiveMap } from 'ably/objects'; - Downside: Breaking change, requires separate type declarations - Benefit: Clean, idiomatic API matching industry conventions Implementation -------------- 1. Added ESM build (objects.mjs) alongside existing UMD build (objects.js) 2. Changed Objects from default to named export in source 3. Updated package.json exports with conditional exports: - "import" -> objects.mjs + objects.d.mts - "require" -> objects.js + objects.d.ts Type Declaration Challenges --------------------------- ESM and CJS require different type declaration approaches: - CJS types use relative imports without extensions: from './ably' - ESM types require .js extensions per Node16 resolution: from './ably.js' We considered: 1. Single .d.ts with compatibility hacks - doesn't satisfy attw validation, fails with "Incorrect default export", see [1] 2. Manually maintained duplicate .d.ts and .d.mts files - error-prone, drift risk 3. Generate .d.mts from .d.ts at build time (chosen) The build:objects:types task transforms objects.d.ts -> objects.d.mts by adding .js extensions to relative imports via regex replacement. Validation ---------- All module scenarios pass attw (arethetypeswrong) validation: - node10: ✓ - node16 (from CJS): ✓ (CJS) - node16 (from ESM): ✓ (ESM) - bundler: ✓ Runtime verified for CJS require, ESM import, and UMD browser global. [1] https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseExportDefault.md --- .gitignore | 1 + Gruntfile.js | 16 ++++++++- grunt/esbuild/build.js | 10 ++++++ objects.d.ts | 18 ++++++---- package.json | 11 +++++-- src/plugins/objects/index.ts | 5 ++- test/package/browser/template/README.md | 2 +- .../browser/template/src/index-objects.ts | 33 ++++++++++++++++--- 8 files changed, 80 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index 1d41eea395..c43e7d430d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ ably-js.iml node_modules npm-debug.log .tool-versions +objects.d.mts build/ react/ typedoc/generated/ diff --git a/Gruntfile.js b/Gruntfile.js index 7ba8985289..c10d197feb 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -138,11 +138,12 @@ module.exports = function (grunt) { }); }); - grunt.registerTask('build:objects', function () { + grunt.registerTask('build:objects:bundle', function () { var done = this.async(); Promise.all([ esbuild.build(esbuildConfig.objectsPluginConfig), + esbuild.build(esbuildConfig.objectsPluginEsmConfig), esbuild.build(esbuildConfig.objectsPluginCdnConfig), esbuild.build(esbuildConfig.minifiedObjectsPluginCdnConfig), ]) @@ -154,6 +155,19 @@ module.exports = function (grunt) { }); }); + grunt.registerTask( + 'build:objects:types', + 'Generate objects.d.mts from objects.d.ts by adding .js extensions to relative imports', + function () { + const dtsContent = fs.readFileSync('objects.d.ts', 'utf8'); + const mtsContent = dtsContent.replace(/from '(\.\/[^']+)'/g, "from '$1.js'"); + fs.writeFileSync('objects.d.mts', mtsContent); + grunt.log.ok('Generated objects.d.mts from objects.d.ts'); + }, + ); + + grunt.registerTask('build:objects', ['build:objects:bundle', 'build:objects:types']); + grunt.registerTask('test:webserver', 'Launch the Mocha test web server on http://localhost:3000/', [ 'build:browser', 'build:push', diff --git a/grunt/esbuild/build.js b/grunt/esbuild/build.js index 2144ce2709..5aea84f3ba 100644 --- a/grunt/esbuild/build.js +++ b/grunt/esbuild/build.js @@ -85,6 +85,15 @@ const objectsPluginConfig = { external: ['dequal'], }; +const objectsPluginEsmConfig = { + ...createBaseConfig(), + format: 'esm', + plugins: [], + entryPoints: ['src/plugins/objects/index.ts'], + outfile: 'build/objects.mjs', + external: ['dequal'], +}; + const objectsPluginCdnConfig = { ...createBaseConfig(), entryPoints: ['src/plugins/objects/index.ts'], @@ -109,6 +118,7 @@ module.exports = { pushPluginCdnConfig, minifiedPushPluginCdnConfig, objectsPluginConfig, + objectsPluginEsmConfig, objectsPluginCdnConfig, minifiedObjectsPluginCdnConfig, }; diff --git a/objects.d.ts b/objects.d.ts index d116d2b6cb..6f77274538 100644 --- a/objects.d.ts +++ b/objects.d.ts @@ -59,24 +59,28 @@ export class LiveCounter { } /** - * Provides a {@link RealtimeClient} instance with the ability to use Objects functionality. + * The Objects plugin that provides a {@link RealtimeClient} instance with the ability to use Objects functionality. * * To create a client that includes this plugin, include it in the client options that you pass to the {@link RealtimeClient.constructor}: * * ```javascript * import { Realtime } from 'ably'; - * import Objects from 'ably/objects'; + * import { Objects } from 'ably/objects'; * const realtime = new Realtime({ ...options, plugins: { Objects } }); * ``` * - * The Objects plugin can also be used with a {@link BaseRealtime} client + * The Objects plugin can also be used with a {@link BaseRealtime} client: * * ```javascript * import { BaseRealtime, WebSocketTransport, FetchRequest } from 'ably/modular'; - * import Objects from 'ably/objects'; + * import { Objects } from 'ably/objects'; * const realtime = new BaseRealtime({ ...options, plugins: { WebSocketTransport, FetchRequest, Objects } }); * ``` + * + * You can also import individual utilities alongside the plugin: + * + * ```javascript + * import { Objects, LiveCounter, LiveMap } from 'ably/objects'; + * ``` */ -declare const Objects: any; - -export = Objects; +export declare const Objects: any; diff --git a/package.json b/package.json index b53f02a364..8edabca7f5 100644 --- a/package.json +++ b/package.json @@ -31,14 +31,21 @@ "import": "./build/push.js" }, "./objects": { - "types": "./objects.d.ts", - "import": "./build/objects.js" + "import": { + "types": "./objects.d.mts", + "default": "./build/objects.mjs" + }, + "require": { + "types": "./objects.d.ts", + "default": "./build/objects.js" + } } }, "files": [ "build/**", "ably.d.ts", "objects.d.ts", + "objects.d.mts", "modular.d.ts", "push.d.ts", "resources/**", diff --git a/src/plugins/objects/index.ts b/src/plugins/objects/index.ts index ddf25c625b..bf0e7c2232 100644 --- a/src/plugins/objects/index.ts +++ b/src/plugins/objects/index.ts @@ -11,7 +11,10 @@ export { WireObjectMessage, }; -export default { +/** + * The named Objects plugin object export to be passed to the Ably client. + */ +export const Objects = { LiveCounter: LiveCounterValueType, LiveMap: LiveMapValueType, ObjectMessage, diff --git a/test/package/browser/template/README.md b/test/package/browser/template/README.md index cc5cbc54c2..e1653d7987 100644 --- a/test/package/browser/template/README.md +++ b/test/package/browser/template/README.md @@ -8,7 +8,7 @@ This directory is intended to be used for testing the following aspects of the a It contains three files, each of which import ably-js in different manners, and provide a way to briefly exercise its functionality: - `src/index-default.ts` imports the default ably-js package (`import { Realtime } from 'ably'`). -- `src/index-objects.ts` imports the Objects ably-js plugin (`import Objects from 'ably/objects'`). +- `src/index-objects.ts` imports the Objects ably-js plugin (`import { Objects } from 'ably/objects'`). - `src/index-modular.ts` imports the tree-shakable ably-js package (`import { BaseRealtime, WebSocketTransport, FetchRequest } from 'ably/modular'`). - `src/ReactApp.tsx` imports React hooks from the ably-js package (`import { useChannel } from 'ably/react'`). diff --git a/test/package/browser/template/src/index-objects.ts b/test/package/browser/template/src/index-objects.ts index b12503418c..d1a9f294c2 100644 --- a/test/package/browser/template/src/index-objects.ts +++ b/test/package/browser/template/src/index-objects.ts @@ -1,6 +1,6 @@ import * as Ably from 'ably'; -import { CompactedValue, LiveCounter, LiveMap } from 'ably'; -import Objects from 'ably/objects'; +import { CompactedJsonValue, CompactedValue, LiveCounter, LiveMap } from 'ably'; +import { Objects } from 'ably/objects'; import { createSandboxAblyAPIKey } from './sandbox'; // Fix for "type 'typeof globalThis' has no index signature" error: @@ -64,7 +64,7 @@ globalThis.testAblyPackage = async function () { }); unsubscribe(); - // compact value + // compact values const compact: CompactedValue> | undefined = myObject.compact(); const compactType: | { @@ -81,8 +81,33 @@ globalThis.testAblyPackage = async function () { | undefined; }; counterKey: number; + arrayBufferKey: ArrayBuffer; + bufferKey: Buffer; + } + | undefined = compact; + + const compactJson: CompactedJsonValue> | undefined = myObject.compactJson(); + const compactJsonType: + | { + numberKey: number; + stringKey: string; + booleanKey: boolean; + couldBeUndefined?: string | undefined; + mapKey: + | { + foo: 'bar'; + nestedMap?: + | { + baz: 'qux'; + } + | { objectId: string } + | undefined; + } + | { objectId: string }; + counterKey: number; arrayBufferKey: string; bufferKey: string; } - | undefined = compact; + | { objectId: string } + | undefined = compactJson; }; From 8e874647c57ed86d43914a313daad16a7cfaac18 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Thu, 18 Dec 2025 08:26:44 +0000 Subject: [PATCH 31/45] Move Objects types from ably.d.ts to objects.d.ts Before this change, developers needed to import Objects types from both 'ably' (Objects interfaces) and 'ably/objects' (LiveMap and LiveCounter classes with static `.create()` methods): ```javascript import * as Ably from 'ably'; import Objects { LiveCounter, LiveMap } from 'ably/objects'; type Project = { score: Ably.LiveCounter; // type from 'ably' }; await project.set('score', LiveCounter.create(0)); // class from 'ably/objects' ``` Now all Objects types are consolidated in 'ably/objects': import { Objects, LiveCounter, LiveMap } from 'ably/objects'; This change also required solving two TypeScript module resolution issues: 1. The __livetype unique symbol (used to brand LiveMap/LiveCounter interfaces) was now being declared separately in both objects.d.ts and objects.d.mts after the changes made in [1] to add support for named exports. Since unique symbols are only equal to themselves, TypeScript saw branded types from these files as incompatible. 2. When ably.d.ts imports from './objects', TypeScript resolves to objects.d.ts. But ESM users importing from 'ably/objects' get objects.d.mts. This caused RealtimeChannel.object to return types from a different module than what users import and cause type mismatches. Solution: - Export __livetype from ably.d.ts so both objects type files import and share the same symbol. - Remove RealtimeObject import from ably.d.ts and use module augmentation in objects.d.ts to add the object property back to RealtimeChannel for projects where users import from 'ably/objects', ensuring all Objects types come from the same module users import from. Alternatives considered: - Keep types in ably.d.ts with re-exports from objects.d.ts: defeats the purpose of consolidating imports to 'ably/objects' export - Create separate ably.d.mts for ESM: cleaner solution but requires additional build infrastructure. This is the ideal solution and the one we will most likely implement in the future as part of the general ably-js type improvements [1] https://github.com/ably/ably-js/pull/2131 --- ably.d.ts | 2032 +---------------- objects.d.ts | 1853 ++++++++++++++- src/plugins/objects/batchcontext.ts | 2 +- src/plugins/objects/instance.ts | 5 +- src/plugins/objects/livecounter.ts | 6 +- src/plugins/objects/livecountervaluetype.ts | 9 +- src/plugins/objects/livemap.ts | 34 +- src/plugins/objects/livemapvaluetype.ts | 17 +- src/plugins/objects/objectmessage.ts | 15 +- src/plugins/objects/pathobject.ts | 5 +- .../objects/pathobjectsubscriptionregister.ts | 8 +- src/plugins/objects/realtimeobject.ts | 9 +- src/plugins/objects/rootbatchcontext.ts | 2 +- .../browser/template/src/index-objects.ts | 31 +- 14 files changed, 2029 insertions(+), 1999 deletions(-) diff --git a/ably.d.ts b/ably.d.ts index 34a71bdfec..b94e279b95 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -299,6 +299,16 @@ export type HTTPMethod = HTTPMethods.GET | HTTPMethods.POST; */ export type Transport = 'web_socket' | 'xhr_polling' | 'comet'; +/** + * Unique symbol used to brand LiveObject interfaces (LiveMap, LiveCounter). + * This enables TypeScript to distinguish between these otherwise empty interfaces, + * which would be structurally identical without this discriminating property. + * + * This symbol is exported from 'ably' so that the types in 'ably/objects' + * (both ESM and CJS versions) share the same symbol, ensuring type compatibility. + */ +export declare const __livetype: unique symbol; + /** * Contains the details of a {@link Channel} or {@link RealtimeChannel} object such as its ID and {@link ChannelStatus}. */ @@ -1652,20 +1662,6 @@ export type ErrorCallback = (error: ErrorInfo | null) => void; */ export type EventCallback = (event: T) => void; -/** - * The callback used for the events emitted by {@link RealtimeObject}. - */ -export type ObjectsEventCallback = () => void; - -/** - * A function passed to the {@link BatchOperations.batch | batch} method to group multiple Objects operations into a single channel message. - * - * The function must be synchronous. - * - * @param ctx - The {@link BatchContext} used to group operations together. - */ -export type BatchFunction = (ctx: BatchContext) => void; - /** * Represents a subscription that can be unsubscribed from. * This interface provides a way to clean up and remove subscriptions when they are no longer needed. @@ -2289,1982 +2285,198 @@ export declare interface PushChannel { } /** - * The `ObjectsEvents` namespace describes the possible values of the {@link ObjectsEvent} type. + * Enables messages to be published and historic messages to be retrieved for a channel. */ -declare namespace ObjectsEvents { - /** - * The local copy of Objects on a channel is currently being synchronized with the Ably service. - */ - type SYNCING = 'syncing'; +export declare interface Channel { /** - * The local copy of Objects on a channel has been synchronized with the Ably service. + * The channel name. */ - type SYNCED = 'synced'; -} - -/** - * Describes the events emitted by a {@link RealtimeObject} object. - */ -export type ObjectsEvent = ObjectsEvents.SYNCED | ObjectsEvents.SYNCING; + name: string; -/** - * Enables the Objects to be read, modified and subscribed to for a channel. - */ -export declare interface RealtimeObject { /** - * Retrieves a {@link PathObject} for the object on a channel. - * - * A type parameter can be provided to describe the structure of the Objects on the channel. - * - * Example: - * - * ```typescript - * import { LiveCounter } from 'ably'; - * - * type MyObject = { - * myTypedCounter: LiveCounter; - * }; - * - * const myTypedObject = await channel.object.get(); - * ``` - * - * @returns A promise which, upon success, will be fulfilled with a {@link PathObject}. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. - * @experimental + * A {@link Presence} object. */ - get>(): Promise>>; - + presence: Presence; /** - * Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. - * - * @param event - The named event to listen for. - * @param callback - The event listener. - * @returns A {@link StatusSubscription} object that allows the provided listener to be deregistered from future updates. - * @experimental + * {@link RestAnnotations} */ - on(event: ObjectsEvent, callback: ObjectsEventCallback): StatusSubscription; - + annotations: RestAnnotations; /** - * Removes all registrations that match both the specified listener and the specified event. - * - * @param event - The named event. - * @param callback - The event listener. - * @experimental + * A {@link PushChannel} object. */ - off(event: ObjectsEvent, callback: ObjectsEventCallback): void; - + push: PushChannel; /** - * Deregisters all registrations, for all events and listeners. + * Retrieves a {@link PaginatedResult} object, containing an array of historical {@link InboundMessage} objects for the channel. If the channel is configured to persist messages, then messages can be retrieved from history for up to 72 hours in the past. If not, messages can only be retrieved from history for up to two minutes in the past. * - * @experimental + * @param params - A set of parameters which are used to specify which messages should be retrieved. + * @returns A promise which, upon success, will be fulfilled with a {@link PaginatedResult} object containing an array of {@link InboundMessage} objects. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. */ - offAll(): void; -} - -/** - * Primitive types that can be stored in collection types. - * Includes JSON-serializable data so that maps and lists can hold plain JS values. - */ -export type Primitive = - | string - | number - | boolean - | Buffer - | ArrayBuffer - // JSON-serializable primitive values - | JsonArray - | JsonObject; - -/** - * Represents a JSON-encodable value. - */ -export type Json = JsonScalar | JsonArray | JsonObject; - -/** - * Represents a JSON-encodable scalar value. - */ -export type JsonScalar = null | boolean | number | string; - -/** - * Represents a JSON-encodable array. - */ -export type JsonArray = Json[]; - -/** - * Represents a JSON-encodable object. - */ -export type JsonObject = { [prop: string]: Json | undefined }; - -/** - * Unique symbol for nominal typing within TypeScript's structural type system. - * This prevents structural compatibility between LiveObject types. - */ -export declare const __livetype: unique symbol; - -// Branded interfaces that enables TypeScript to distinguish -// between LiveObject types even when they have identical structure (empty interfaces in this case). -// Enables PathObject to dispatch to correct method sets via conditional types. -/** - * A {@link LiveMap} is a collection type that maps string keys to values, which can be either primitive values or other LiveObjects. - */ -export interface LiveMap<_T extends Record = Record> { - /** LiveMap type symbol */ - [__livetype]: 'LiveMap'; -} - -/** - * A {@link LiveCounter} is a numeric type that supports atomic increment and decrement operations. - */ -export interface LiveCounter { - /** LiveCounter type symbol */ - [__livetype]: 'LiveCounter'; -} - -/** - * Type union that matches any LiveObject type that can be mutated, subscribed to, etc. - */ -export type LiveObject = LiveMap | LiveCounter; - -/** - * Type union that defines the base set of allowed types that can be stored in collection types. - * Describes the set of all possible values that can parameterize PathObject. - * This is the canonical union used when a narrower type cannot be inferred. - */ -export type Value = LiveObject | Primitive; - -/** - * CompactedValue transforms LiveObject types into in-memory JavaScript equivalents. - * LiveMap becomes an object, LiveCounter becomes a number, primitive values remain unchanged. - */ -export type CompactedValue = - // LiveMap types - [T] extends [LiveMap] - ? { [K in keyof U]: CompactedValue } - : [T] extends [LiveMap | undefined] - ? { [K in keyof U]: CompactedValue } | undefined - : // LiveCounter types - [T] extends [LiveCounter] - ? number - : [T] extends [LiveCounter | undefined] - ? number | undefined - : // Other primitive types - [T] extends [Primitive] - ? T - : [T] extends [Primitive | undefined] - ? T - : any; - -/** - * Represents a cyclic object reference in a JSON-serializable format. - */ -export interface ObjectIdReference { - /** The referenced object Id. */ - objectId: string; -} - -/** - * CompactedJsonValue transforms LiveObject types into JSON-serializable equivalents. - * LiveMap becomes an object, LiveCounter becomes a number, binary values become base64-encoded strings, - * other primitives remain unchanged. - * - * Additionally, cyclic references are represented as `{ objectId: string }` instead of in-memory pointers to same objects. - */ -export type CompactedJsonValue = - // LiveMap types - note: cyclic references become ObjectIdReference - [T] extends [LiveMap] - ? { [K in keyof U]: CompactedJsonValue } | ObjectIdReference - : [T] extends [LiveMap | undefined] - ? { [K in keyof U]: CompactedJsonValue } | ObjectIdReference | undefined - : // LiveCounter types - [T] extends [LiveCounter] - ? number - : [T] extends [LiveCounter | undefined] - ? number | undefined - : // Binary types (converted to base64 strings) - [T] extends [ArrayBuffer] - ? string - : [T] extends [ArrayBuffer | undefined] - ? string | undefined - : [T] extends [ArrayBufferView] - ? string - : [T] extends [ArrayBufferView | undefined] - ? string | undefined - : // Other primitive types - [T] extends [Primitive] - ? T - : [T] extends [Primitive | undefined] - ? T - : any; - -/** - * PathObjectBase defines the set of common methods on a PathObject - * that are present regardless of the underlying type. - */ -interface PathObjectBase { + history(params?: RestHistoryParams): Promise>; /** - * Get the fully-qualified path string for this PathObject. - * - * Path segments with dots in them are escaped with a backslash. - * For example, a path with segments `['a', 'b.c', 'd']` will be represented as `a.b\.c.d`. + * Publishes an array of messages to the channel. * - * @experimental + * @param messages - An array of {@link Message} objects. + * @param options - Optional parameters, such as [`quickAck`](https://faqs.ably.com/why-are-some-rest-publishes-on-a-channel-slow-and-then-typically-faster-on-subsequent-publishes) sent as part of the query string. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. */ - path(): string; - + publish(messages: Message[], options?: PublishOptions): Promise; /** - * Registers a listener that is called each time the object or a primitive value at this path is updated. - * - * The provided listener receives a {@link PathObject} representing the updated path, - * and, if applicable, an {@link ObjectMessage} that carried the operation that led to the change. - * - * By default, subscriptions observe nested changes, but you can configure the observation depth - * using the `options` parameter. - * - * A PathObject subscription observes whichever value currently exists at this path. - * The subscription remains active even if the path temporarily does not resolve to any value - * (for example, if an entry is removed from a map). If the object instance at this path changes, - * the subscription automatically switches to observe the new instance and stops observing the old one. + * Publishes a message to the channel. * - * @param listener - An event listener function. - * @param options - Optional subscription configuration. - * @returns A {@link Subscription} object that allows the provided listener to be deregistered from future updates. - * @experimental + * @param message - A {@link Message} object. + * @param options - Optional parameters, such as [`quickAck`](https://faqs.ably.com/why-are-some-rest-publishes-on-a-channel-slow-and-then-typically-faster-on-subsequent-publishes) sent as part of the query string. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. */ - subscribe( - listener: EventCallback, - options?: PathObjectSubscriptionOptions, - ): Subscription; - + publish(message: Message, options?: PublishOptions): Promise; /** - * Registers a subscription listener and returns an async iterator that yields - * subscription events each time the object or a primitive value at this path is updated. - * - * This method functions in the same way as the regular {@link PathObjectBase.subscribe | PathObject.subscribe()} method, - * but instead returns an async iterator that can be used in a `for await...of` loop for convenience. + * Publishes a single message to the channel with the given event name and payload. * - * @param options - Optional subscription configuration. - * @returns An async iterator that yields {@link PathObjectSubscriptionEvent} objects. - * @experimental + * @param name - The name of the message. + * @param data - The payload of the message. + * @param options - Optional parameters, such as [`quickAck`](https://faqs.ably.com/why-are-some-rest-publishes-on-a-channel-slow-and-then-typically-faster-on-subsequent-publishes) sent as part of the query string. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. */ - subscribeIterator(options?: PathObjectSubscriptionOptions): AsyncIterableIterator; -} - -/** - * PathObjectCollectionMethods defines the set of common methods on a PathObject - * that are present for any collection type, regardless of the specific underlying type. - */ -interface PathObjectCollectionMethods { + publish(name: string, data: any, options?: PublishOptions): Promise; /** - * Collection types support obtaining a PathObject with a fully-qualified string path, - * which is evaluated from the current path. - * Using this method loses rich compile-time type information. + * Retrieves a {@link ChannelDetails} object for the channel, which includes status and occupancy metrics. * - * @param path - A fully-qualified path string to navigate to, relative to the current path. - * @returns A {@link PathObject} for the specified path. - * @experimental + * @returns A promise which, upon success, will be fulfilled a {@link ChannelDetails} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. */ - at(path: string): PathObject; -} - -/** - * Defines collection methods available on a {@link LiveMapPathObject}. - */ -interface LiveMapPathObjectCollectionMethods = Record> { + status(): Promise; /** - * Returns an iterable of key-value pairs for each entry in the map at this path. - * Each value is represented as a {@link PathObject} corresponding to its key. - * - * If the path does not resolve to a map object, returns an empty iterator. + * Retrieves the latest version of a specific message by its serial identifier. * - * @experimental + * @param serialOrMessage - Either the serial identifier string of the message to retrieve, or a {@link Message} object containing a populated `serial` field. + * @returns A promise which, upon success, will be fulfilled with a {@link Message} object representing the latest version of the message. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. */ - entries(): IterableIterator<[keyof T, PathObject]>; - + getMessage(serialOrMessage: string | Message): Promise; /** - * Returns an iterable of keys in the map at this path. - * - * If the path does not resolve to a map object, returns an empty iterator. + * Publishes an update to an existing message with patch semantics. Non-null `name`, `data`, and `extras` fields in the provided message will replace the corresponding fields in the existing message, while null fields will be left unchanged. * - * @experimental + * @param message - A {@link Message} object containing a populated `serial` field and the fields to update. + * @param operation - An optional {@link MessageOperation} object containing metadata about the update operation. + * @param params - Optional parameters sent as part of the query string. + * @returns A promise which on success will be fulfilled, and on failure, rejected with an {@link ErrorInfo} object which explains the error. */ - keys(): IterableIterator; - + updateMessage(message: Message, operation?: MessageOperation, params?: Record): Promise; /** - * Returns an iterable of values in the map at this path. - * Each value is represented as a {@link PathObject}. - * - * If the path does not resolve to a map object, returns an empty iterator. + * Marks a message as deleted by publishing an update with an action of `MESSAGE_DELETE`. This does not remove the message from the server, and the full message history remains accessible. Uses patch semantics: non-null `name`, `data`, and `extras` fields in the provided message will replace the corresponding fields in the existing message, while null fields will be left unchanged (meaning that if you for example want the `MESSAGE_DELETE` to have an empty data, you should explicitly set the `data` to an empty object). * - * @experimental + * @param message - A {@link Message} object containing a populated `serial` field. + * @param operation - An optional {@link MessageOperation} object containing metadata about the delete operation. + * @param params - Optional parameters sent as part of the query string. + * @returns A promise which on success will be fulfilled, and on failure, rejected with an {@link ErrorInfo} object which explains the error. */ - values(): IterableIterator>; - + deleteMessage(message: Message, operation?: MessageOperation, params?: Record): Promise; /** - * Returns the number of entries in the map at this path. - * - * If the path does not resolve to a map object, returns `undefined`. + * Retrieves all historical versions of a specific message, ordered by version. This includes the original message and all subsequent updates or delete operations. * - * @experimental + * @param serialOrMessage - Either the serial identifier string of the message whose versions are to be retrieved, or a {@link Message} object containing a populated `serial` field. + * @param params - Optional parameters sent as part of the query string. + * @returns A promise which, upon success, will be fulfilled with a {@link PaginatedResult} object containing an array of {@link Message} objects representing all versions of the message. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. */ - size(): number | undefined; + getMessageVersions( + serialOrMessage: string | Message, + params?: Record, + ): Promise>; } /** - * A PathObject representing a {@link LiveMap} instance at a specific path. - * The type parameter T describes the expected structure of the map's entries. + * Functionality for annotating messages with small pieces of data, such as emoji + * reactions, that the server will roll up into the message as a summary. */ -export interface LiveMapPathObject = Record> - extends PathObjectBase, - PathObjectCollectionMethods, - LiveMapPathObjectCollectionMethods, - LiveMapOperations { +export declare interface RestAnnotations { /** - * Navigate to a child path within the map by obtaining a PathObject for that path. - * The next path segment in a LiveMap is identified with a string key. + * Publish a new annotation for a message. * - * @param key - A string key for the next path segment within the map. - * @returns A {@link PathObject} for the specified key. - * @experimental + * @param message - The message to annotate. + * @param annotation - The annotation to publish. (Must include at least the `type`. + * Assumed to be an annotation.create if no action is specified) */ - get(key: K): PathObject; - + publish(message: Message, annotation: OutboundAnnotation): Promise; /** - * Get the specific map instance currently at this path. - * If the path does not resolve to any specific instance, returns `undefined`. + * Publish a new annotation for a message (alternative form where you only have the + * serial of the message, not a complete Message object) * - * @returns The {@link LiveMapInstance} at this path, or `undefined` if none exists. - * @experimental + * @param messageSerial - The serial field of the message to annotate. + * @param annotation - The annotation to publish. (Must include at least the `type`. + * Assumed to be an annotation.create if no action is specified) */ - instance(): LiveMapInstance | undefined; - + publish(messageSerial: string, annotation: OutboundAnnotation): Promise; /** - * Get an in-memory JavaScript object representation of the map at this path. - * 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`. - * - * Use {@link LiveMapPathObject.compactJson | compactJson()} for a JSON-serializable representation. + * Get all annotations for a given message (as a paginated result) * - * @experimental + * @param message - The message to get annotations for. + * @param params - Restrictions on which annotations to get (in particular a limit) */ - compact(): CompactedValue> | undefined; - + get(message: Message, params: GetAnnotationsParams | null): Promise>; /** - * Get a JSON-serializable representation of the map at this path. - * Binary values are converted to base64-encoded strings. - * Cyclic references are represented as `{ objectId: string }` instead of in-memory pointers, - * making the result safe to pass to `JSON.stringify()`. - * - * If the path does not resolve to any specific instance, returns `undefined`. - * - * Use {@link LiveMapPathObject.compact | compact()} for an in-memory representation. + * Get all annotations for a given message (as a paginated result) (alternative form + * where you only have the serial of the message, not a complete Message object) * - * @experimental + * @param messageSerial - The `serial` of the message to get annotations for. + * @param params - Restrictions on which annotations to get (in particular a limit) */ - compactJson(): CompactedJsonValue> | undefined; + get(messageSerial: string, params: GetAnnotationsParams | null): Promise>; } /** - * A PathObject representing a {@link LiveCounter} instance at a specific path. + * Enables messages to be published and subscribed to. Also enables historic messages to be retrieved and provides access to the {@link RealtimePresence} object of a channel. */ -export interface LiveCounterPathObject extends PathObjectBase, LiveCounterOperations { +export declare interface RealtimeChannel extends EventEmitter { /** - * Get the current value of the counter instance currently at this path. - * If the path does not resolve to any specific instance, returns `undefined`. - * - * @experimental + * The channel name. */ - value(): number | undefined; - + readonly name: string; /** - * Get the specific counter instance currently at this path. - * If the path does not resolve to any specific instance, returns `undefined`. - * - * @returns The {@link LiveCounterInstance} at this path, or `undefined` if none exists. - * @experimental + * An {@link ErrorInfo} object describing the last error which occurred on the channel, if any. */ - instance(): LiveCounterInstance | undefined; - + errorReason: ErrorInfo; /** - * Get a number representation of the counter at this path. - * This is an alias for calling {@link LiveCounterPathObject.value | value()}. - * - * If the path does not resolve to any specific instance, returns `undefined`. - * - * @experimental + * The current {@link ChannelState} of the channel. */ - compact(): CompactedValue | undefined; - + readonly state: ChannelState; /** - * Get a number representation of the counter at this path. - * This is an alias for calling {@link LiveCounterPathObject.value | value()}. - * - * If the path does not resolve to any specific instance, returns `undefined`. - * - * @experimental + * Optional [channel parameters](https://ably.com/docs/realtime/channels/channel-parameters/overview) that configure the behavior of the channel. */ - compactJson(): CompactedJsonValue | undefined; -} - -/** - * A PathObject representing a primitive value at a specific path. - */ -export interface PrimitivePathObject extends PathObjectBase { + params: ChannelParams; /** - * Get the current value of the primitive currently at this path. - * If the path does not resolve to any specific entry, returns `undefined`. - * - * @experimental + * An array of {@link ResolvedChannelMode} objects. */ - value(): T | undefined; - + modes: ResolvedChannelMode[]; /** - * Get a JavaScript object representation of the primitive value at this path. - * This is an alias for calling {@link PrimitivePathObject.value | value()}. - * - * If the path does not resolve to any specific entry, returns `undefined`. + * Deregisters the given listener for the specified event name. This removes an earlier event-specific subscription. * - * @experimental + * @param event - The event name. + * @param listener - An event listener function. */ - compact(): CompactedValue | undefined; - + unsubscribe(event: string, listener: messageCallback): void; /** - * Get a JSON-serializable representation of the primitive value at this path. - * Binary values are converted to base64-encoded strings. - * - * If the path does not resolve to any specific entry, returns `undefined`. + * Deregisters the given listener from all event names in the array. * - * @experimental + * @param events - An array of event names. + * @param listener - An event listener function. */ - compactJson(): CompactedJsonValue | undefined; -} - -/** - * AnyPathObjectCollectionMethods defines all possible methods available on a PathObject - * for the underlying collection types. - */ -interface AnyPathObjectCollectionMethods { - // LiveMap collection methods - + unsubscribe(events: Array, listener: messageCallback): void; /** - * Returns an iterable of key-value pairs for each entry in the map, if the path resolves to a {@link LiveMap}. - * Each value is represented as a {@link PathObject} corresponding to its key. - * - * If the path does not resolve to a map object, returns an empty iterator. + * Deregisters all listeners for the given event name. * - * @experimental + * @param event - The event name. */ - entries>(): IterableIterator<[keyof T, PathObject]>; - + unsubscribe(event: string): void; /** - * Returns an iterable of keys in the map, if the path resolves to a {@link LiveMap}. - * - * If the path does not resolve to a map object, returns an empty iterator. + * Deregisters all listeners for all event names in the array. * - * @experimental + * @param events - An array of event names. */ - keys>(): IterableIterator; - + unsubscribe(events: Array): void; /** - * Returns an iterable of values in the map, if the path resolves to a {@link LiveMap}. - * Each value is represented as a {@link PathObject}. - * - * If the path does not resolve to a map object, returns an empty iterator. + * Deregisters all listeners to messages on this channel that match the supplied filter. * - * @experimental + * @param filter - A {@link MessageFilter}. + * @param listener - An event listener function. */ - values>(): IterableIterator>; - - /** - * Returns the number of entries in the map, if the path resolves to a {@link LiveMap}. - * - * If the path does not resolve to a map object, returns `undefined`. - * - * @experimental - */ - size(): number | undefined; -} - -/** - * Represents a {@link PathObject} when its underlying type is not known. - * Provides a unified interface that includes all possible methods. - * - * Each method supports type parameters to specify the expected - * underlying type when needed. - */ -export interface AnyPathObject - extends PathObjectBase, - PathObjectCollectionMethods, - AnyPathObjectCollectionMethods, - AnyOperations { - /** - * Navigate to a child path within the collection by obtaining a PathObject for that path. - * The next path segment in a collection is identified with a string key. - * - * @param key - A string key for the next path segment within the collection. - * @returns A {@link PathObject} for the specified key. - * @experimental - */ - get(key: string): PathObject; - - /** - * Get the current value of the LiveCounter or primitive currently at this path. - * If the path does not resolve to any specific entry, returns `undefined`. - * - * @experimental - */ - value(): T | undefined; - - /** - * Get the specific object instance currently at this path. - * If the path does not resolve to any specific instance, returns `undefined`. - * - * @returns The object instance at this path, or `undefined` if none exists. - * @experimental - */ - instance(): Instance | undefined; - - /** - * Get an in-memory JavaScript object representation of the object at this path. - * For primitive types, this is an alias for calling {@link AnyPathObject.value | value()}. - * - * 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`. - * - * Use {@link AnyPathObject.compactJson | compactJson()} for a JSON-serializable representation. - * - * @experimental - */ - compact(): CompactedValue | undefined; - - /** - * Get a JSON-serializable representation of the object at this path. - * Binary values are converted to base64-encoded strings. - * - * When compacting a {@link LiveMap}, cyclic references are represented as `{ objectId: string }` - * instead of in-memory pointers, making the result safe to pass to `JSON.stringify()`. - * - * If the path does not resolve to any specific entry, returns `undefined`. - * - * Use {@link AnyPathObject.compact | compact()} for an in-memory representation. - * - * @experimental - */ - compactJson(): CompactedJsonValue | undefined; -} - -/** - * PathObject wraps a reference to a path starting from the entrypoint object on a channel. - * The type parameter specifies the underlying type defined at that path, - * and is used to infer the correct set of methods available for that type. - * - * @experimental - */ -export type PathObject = [T] extends [LiveMap] - ? LiveMapPathObject - : [T] extends [LiveCounter] - ? LiveCounterPathObject - : [T] extends [Primitive] - ? PrimitivePathObject - : AnyPathObject; - -/** - * BatchContextBase defines the set of common methods on a BatchContext - * that are present regardless of the underlying type. - */ -interface BatchContextBase { - /** - * Get the object ID of the underlying instance. - * - * If the underlying instance at runtime is not a {@link LiveObject}, returns `undefined`. - * - * @experimental - */ - readonly id: string | undefined; -} - -/** - * Defines collection methods available on a {@link LiveMapBatchContext}. - */ -interface LiveMapBatchContextCollectionMethods = Record> { - /** - * Returns an iterable of key-value pairs for each entry in the map. - * Each value is represented as a {@link BatchContext} corresponding to its key. - * - * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental - */ - entries(): IterableIterator<[keyof T, BatchContext]>; - - /** - * Returns an iterable of keys in the map. - * - * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental - */ - keys(): IterableIterator; - - /** - * Returns an iterable of values in the map. - * Each value is represented as a {@link BatchContext}. - * - * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental - */ - values(): IterableIterator>; - - /** - * Returns the number of entries in the map. - * - * If the underlying instance at runtime is not a map, returns `undefined`. - * - * @experimental - */ - size(): number | undefined; -} - -/** - * LiveMapBatchContext is a batch context wrapper for a LiveMap object. - * The type parameter T describes the expected structure of the map's entries. - */ -export interface LiveMapBatchContext = Record> - extends BatchContextBase, - BatchContextLiveMapOperations, - LiveMapBatchContextCollectionMethods { - /** - * Returns the value associated with a given key as a {@link BatchContext}. - * - * Returns `undefined` if the key doesn't exist in the map, if the referenced {@link LiveObject} has been deleted, - * or if this map object itself has been deleted. - * - * @param key - The key to retrieve the value for. - * @returns A {@link BatchContext} representing a {@link LiveObject}, a primitive type (string, number, boolean, JSON-serializable object or array, or binary data) or `undefined` if the key doesn't exist in a map or the referenced {@link LiveObject} has been deleted. Always `undefined` if this map object is deleted. - * @experimental - */ - get(key: K): BatchContext | undefined; - - /** - * Get an in-memory JavaScript object representation of the map instance. - * 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`. - * - * Use {@link LiveMapBatchContext.compactJson | compactJson()} for a JSON-serializable representation. - * - * @experimental - */ - compact(): CompactedValue> | undefined; - - /** - * Get a JSON-serializable representation of the map instance. - * Binary values are converted to base64-encoded strings. - * Cyclic references are represented as `{ objectId: string }` instead of in-memory pointers, - * making the result safe to pass to `JSON.stringify()`. - * - * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * Use {@link LiveMapBatchContext.compact | compact()} for an in-memory representation. - * - * @experimental - */ - compactJson(): CompactedJsonValue> | undefined; -} - -/** - * LiveCounterBatchContext is a batch context wrapper for a LiveCounter object. - */ -export interface LiveCounterBatchContext extends BatchContextBase, BatchContextLiveCounterOperations { - /** - * Get the current value of the counter instance. - * If the underlying instance at runtime is not a counter, returns `undefined`. - * - * @experimental - */ - value(): number | undefined; - - /** - * Get a number representation of the counter instance. - * This is an alias for calling {@link LiveCounterBatchContext.value | value()}. - * - * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * @experimental - */ - compact(): CompactedValue | undefined; - - /** - * Get a number representation of the counter instance. - * This is an alias for calling {@link LiveCounterBatchContext.value | value()}. - * - * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * @experimental - */ - compactJson(): CompactedJsonValue | undefined; -} - -/** - * PrimitiveBatchContext is a batch context wrapper for a primitive value (string, number, boolean, JSON-serializable object or array, or binary data). - */ -export interface PrimitiveBatchContext { - /** - * Get the underlying primitive value. - * If the underlying instance at runtime is not a primitive value, returns `undefined`. - * - * @experimental - */ - value(): T | undefined; - - /** - * Get a JavaScript object representation of the primitive value. - * This is an alias for calling {@link PrimitiveBatchContext.value | value()}. - * - * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * @experimental - */ - compact(): CompactedValue | undefined; - - /** - * Get a JSON-serializable representation of the primitive value. - * Binary values are converted to base64-encoded strings. - * - * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * @experimental - */ - compactJson(): CompactedJsonValue | undefined; -} - -/** - * AnyBatchContextCollectionMethods defines all possible methods available on an BatchContext object - * for the underlying collection types. - */ -interface AnyBatchContextCollectionMethods { - // LiveMap collection methods - - /** - * Returns an iterable of key-value pairs for each entry in the map. - * Each value is represented as an {@link BatchContext} corresponding to its key. - * - * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental - */ - entries>(): IterableIterator<[keyof T, BatchContext]>; - - /** - * Returns an iterable of keys in the map. - * - * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental - */ - keys>(): IterableIterator; - - /** - * Returns an iterable of values in the map. - * Each value is represented as a {@link BatchContext}. - * - * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental - */ - values>(): IterableIterator>; - - /** - * Returns the number of entries in the map. - * - * If the underlying instance at runtime is not a map, returns `undefined`. - * - * @experimental - */ - size(): number | undefined; -} - -/** - * Represents a {@link BatchContext} when its underlying type is not known. - * Provides a unified interface that includes all possible methods. - * - * Each method supports type parameters to specify the expected - * underlying type when needed. - */ -export interface AnyBatchContext extends BatchContextBase, AnyBatchContextCollectionMethods, BatchContextAnyOperations { - /** - * Navigate to a child entry within the collection by obtaining the {@link BatchContext} at that entry. - * The entry in a collection is identified with a string key. - * - * Returns `undefined` if: - * - The underlying instance at runtime is not a collection object. - * - The specified key does not exist in the collection. - * - The referenced {@link LiveObject} has been deleted. - * - This collection object itself has been deleted. - * - * @param key - The key to retrieve the value for. - * @returns A {@link BatchContext} representing either a {@link LiveObject} or a primitive value (string, number, boolean, JSON-serializable object or array, or binary data), or `undefined` if the underlying instance at runtime is not a collection object, the key does not exist, the referenced {@link LiveObject} has been deleted, or this collection object itself has been deleted. - * @experimental - */ - get(key: string): BatchContext | undefined; - - /** - * Get the current value of the underlying counter or primitive. - * - * If the underlying instance at runtime is neither a counter nor a primitive value, returns `undefined`. - * - * @returns The current value of the underlying primitive or counter, or `undefined` if the value cannot be retrieved. - * @experimental - */ - value(): T | undefined; - - /** - * Get an in-memory JavaScript object representation of the object instance. - * For primitive types, this is an alias for calling {@link AnyBatchContext.value | value()}. - * - * 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`. - * - * Use {@link AnyBatchContext.compactJson | compactJson()} for a JSON-serializable representation. - * - * @experimental - */ - compact(): CompactedValue | undefined; - - /** - * Get a JSON-serializable representation of the object instance. - * Binary values are converted to base64-encoded strings. - * - * When compacting a {@link LiveMap}, cyclic references are represented as `{ objectId: string }` - * instead of in-memory pointers, making the result safe to pass to `JSON.stringify()`. - * - * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * Use {@link AnyBatchContext.compact | compact()} for an in-memory representation. - * - * @experimental - */ - compactJson(): CompactedJsonValue | undefined; -} - -/** - * BatchContext wraps a specific object instance or entry in a specific collection - * object instance and provides synchronous operation methods that can be aggregated - * and applied as a single batch operation. - * - * The type parameter specifies the underlying type of the instance, - * and is used to infer the correct set of methods available for that type. - * - * @experimental - */ -export type BatchContext = [T] extends [LiveMap] - ? LiveMapBatchContext - : [T] extends [LiveCounter] - ? LiveCounterBatchContext - : [T] extends [Primitive] - ? PrimitiveBatchContext - : AnyBatchContext; - -/** - * Defines operations available on {@link LiveMapBatchContext}. - */ -export interface BatchContextLiveMapOperations = Record> { - /** - * Adds an operation to the current batch to set a key to a specified value on the underlying - * {@link LiveMapInstance}. All queued operations are sent together in a single message once the - * batch function completes. - * - * If the underlying instance at runtime is not a map, this method throws an error. - * - * This does not modify the underlying data of the map. Instead, the change is applied when - * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. - * - * @param key - The key to set the value for. - * @param value - The value to assign to the key. - * @experimental - */ - set(key: K, value: T[K]): void; - - /** - * Adds an operation to the current batch to remove a key from the underlying - * {@link LiveMapInstance}. All queued operations are sent together in a single message once the - * batch function completes. - * - * If the underlying instance at runtime is not a map, this method throws an error. - * - * This does not modify the underlying data of the map. Instead, the change is applied when - * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. - * - * @param key - The key to remove. - * @experimental - */ - remove(key: keyof T & string): void; -} - -/** - * Defines operations available on {@link LiveCounterBatchContext}. - */ -export interface BatchContextLiveCounterOperations { - /** - * Adds an operation to the current batch to increment the value of the underlying - * {@link LiveCounterInstance}. All queued operations are sent together in a single message once the - * batch function completes. - * - * If the underlying instance at runtime is not a counter, this method throws an error. - * - * This does not modify the underlying data of the counter. Instead, the change is applied when - * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. - * - * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. - * @experimental - */ - increment(amount?: number): void; - - /** - * An alias for calling {@link BatchContextLiveCounterOperations.increment | increment(-amount)} - * - * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. - * @experimental - */ - decrement(amount?: number): void; -} - -/** - * Defines all possible operations available on {@link BatchContext} objects. - */ -export interface BatchContextAnyOperations { - // LiveMap operations - - /** - * Adds an operation to the current batch to set a key to a specified value on the underlying - * {@link LiveMapInstance}. All queued operations are sent together in a single message once the - * batch function completes. - * - * If the underlying instance at runtime is not a map, this method throws an error. - * - * This does not modify the underlying data of the map. Instead, the change is applied when - * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. - * - * @param key - The key to set the value for. - * @param value - The value to assign to the key. - * @experimental - */ - set = Record>(key: keyof T & string, value: T[keyof T]): void; - - /** - * Adds an operation to the current batch to remove a key from the underlying - * {@link LiveMapInstance}. All queued operations are sent together in a single message once the - * batch function completes. - * - * If the underlying instance at runtime is not a map, this method throws an error. - * - * This does not modify the underlying data of the map. Instead, the change is applied when - * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. - * - * @param key - The key to remove. - * @experimental - */ - remove = Record>(key: keyof T & string): void; - - // LiveCounter operations - - /** - * Adds an operation to the current batch to increment the value of the underlying - * {@link LiveCounterInstance}. All queued operations are sent together in a single message once the - * batch function completes. - * - * If the underlying instance at runtime is not a counter, this method throws an error. - * - * This does not modify the underlying data of the counter. Instead, the change is applied when - * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. - * - * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. - * @experimental - */ - increment(amount?: number): void; - - /** - * An alias for calling {@link BatchContextAnyOperations.increment | increment(-amount)} - * - * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. - * @experimental - */ - decrement(amount?: number): void; -} - -/** - * Defines batch operations available on {@link LiveObject | LiveObjects}. - */ -export interface BatchOperations { - /** - * Batch multiple operations together using a batch context, which - * wraps the underlying {@link PathObject} or {@link Instance} from which the batch was called. - * The batch context always contains a resolved instance, even when called from a {@link PathObject}. - * If an instance cannot be resolved from the referenced path, or if the instance is not a {@link LiveObject}, - * this method throws an error. - * - * Batching enables you to group multiple operations together and send them to the Ably service in a single channel message. - * As a result, other clients will receive the changes in a single channel message once the batch function has completed. - * - * The objects' data is not modified inside the batch function. Instead, the objects will be updated - * when the batched operations are applied by the Ably service and echoed back to the client. - * - * @param fn - A synchronous function that receives a {@link BatchContext} used to group operations together. - * @returns A promise which resolves upon success of the batch operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental - */ - batch(fn: BatchFunction): Promise; -} - -/** - * Defines operations available on {@link LiveMap} objects. - */ -export interface LiveMapOperations = Record> - extends BatchOperations> { - /** - * Sends an operation to the Ably system to set a key to a specified value on a given {@link LiveMapInstance}, - * or on the map instance resolved from the path when using {@link LiveMapPathObject}. - * - * If called via {@link LiveMapInstance} and the underlying instance at runtime is not a map, - * or if called via {@link LiveMapPathObject} and the map instance at the specified path cannot - * be resolved at the time of the call, this method throws an error. - * - * This does not modify the underlying data of the map. Instead, the change is applied when - * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. - * - * @param key - The key to set the value for. - * @param value - The value to assign to the key. - * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental - */ - set(key: K, value: T[K]): Promise; - - /** - * Sends an operation to the Ably system to remove a key from a given {@link LiveMapInstance}, - * or from the map instance resolved from the path when using {@link LiveMapPathObject}. - * - * If called via {@link LiveMapInstance} and the underlying instance at runtime is not a map, - * or if called via {@link LiveMapPathObject} and the map instance at the specified path cannot - * be resolved at the time of the call, this method throws an error. - * - * This does not modify the underlying data of the map. Instead, the change is applied when - * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. - * - * @param key - The key to remove. - * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental - */ - remove(key: keyof T & string): Promise; -} - -/** - * Defines operations available on {@link LiveCounter} objects. - */ -export interface LiveCounterOperations extends BatchOperations { - /** - * Sends an operation to the Ably system to increment the value of a given {@link LiveCounterInstance}, - * or of the counter instance resolved from the path when using {@link LiveCounterPathObject}. - * - * If called via {@link LiveCounterInstance} and the underlying instance at runtime is not a counter, - * or if called via {@link LiveCounterPathObject} and the counter instance at the specified path cannot - * be resolved at the time of the call, this method throws an error. - * - * This does not modify the underlying data of the counter. Instead, the change is applied when - * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. - * - * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. - * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental - */ - increment(amount?: number): Promise; - - /** - * An alias for calling {@link LiveCounterOperations.increment | increment(-amount)} - * - * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. - * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental - */ - decrement(amount?: number): Promise; -} - -/** - * Defines all possible operations available on {@link LiveObject | LiveObjects}. - */ -export interface AnyOperations { - /** - * Batch multiple operations together using a batch context, which - * wraps the underlying {@link PathObject} or {@link Instance} from which the batch was called. - * The batch context always contains a resolved instance, even when called from a {@link PathObject}. - * If an instance cannot be resolved from the referenced path, or if the instance is not a {@link LiveObject}, - * this method throws an error. - * - * Batching enables you to group multiple operations together and send them to the Ably service in a single channel message. - * As a result, other clients will receive the changes in a single channel message once the batch function has completed. - * - * The objects' data is not modified inside the batch function. Instead, the objects will be updated - * when the batched operations are applied by the Ably service and echoed back to the client. - * - * @param fn - A synchronous function that receives a {@link BatchContext} used to group operations together. - * @returns A promise which resolves upon success of the batch operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental - */ - batch(fn: BatchFunction): Promise; - - // LiveMap operations - - /** - * Sends an operation to the Ably system to set a key to a specified value on the underlying map when using {@link AnyInstance}, - * or on the map instance resolved from the path when using {@link AnyPathObject}. - * - * If called via {@link AnyInstance} and the underlying instance at runtime is not a map, - * or if called via {@link AnyPathObject} and the map instance at the specified path cannot - * be resolved at the time of the call, this method throws an error. - * - * This does not modify the underlying data of the map. Instead, the change is applied when - * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. - * - * @param key - The key to set the value for. - * @param value - The value to assign to the key. - * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental - */ - set = Record>(key: keyof T & string, value: T[keyof T]): Promise; - - /** - * Sends an operation to the Ably system to remove a key from the underlying map when using {@link AnyInstance}, - * or from the map instance resolved from the path when using {@link AnyPathObject}. - * - * If called via {@link AnyInstance} and the underlying instance at runtime is not a map, - * or if called via {@link AnyPathObject} and the map instance at the specified path cannot - * be resolved at the time of the call, this method throws an error. - * - * This does not modify the underlying data of the map. Instead, the change is applied when - * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. - * - * @param key - The key to remove. - * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental - */ - remove = Record>(key: keyof T & string): Promise; - - // LiveCounter operations - - /** - * Sends an operation to the Ably system to increment the value of the underlying counter when using {@link AnyInstance}, - * or of the counter instance resolved from the path when using {@link AnyPathObject}. - * - * If called via {@link AnyInstance} and the underlying instance at runtime is not a counter, - * or if called via {@link AnyPathObject} and the counter instance at the specified path cannot - * be resolved at the time of the call, this method throws an error. - * - * This does not modify the underlying data of the counter. Instead, the change is applied when - * the published operation is echoed back to the client and applied to the object. - * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. - * - * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. - * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental - */ - increment(amount?: number): Promise; - - /** - * An alias for calling {@link AnyOperations.increment | increment(-amount)} - * - * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. - * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental - */ - decrement(amount?: number): Promise; -} - -/** - * InstanceBase defines the set of common methods on an Instance - * that are present regardless of the underlying type specified in the type parameter T. - */ -interface InstanceBase { - /** - * Get the object ID of this instance. - * - * If the underlying instance at runtime is not a {@link LiveObject}, returns `undefined`. - * - * @experimental - */ - readonly id: string | undefined; - - /** - * Registers a listener that is called each time this instance is updated. - * - * If the underlying instance at runtime is not a {@link LiveObject}, this method throws an error. - * - * The provided listener receives an {@link Instance} representing the updated object, - * and, if applicable, an {@link ObjectMessage} that carried the operation that led to the change. - * - * Instance subscriptions track a specific object instance regardless of its location. - * The subscription follows the instance if it is moved within the broader structure - * (for example, between map entries). - * - * If the instance is deleted from the channel object entirely (i.e., tombstoned), - * the listener is called with the corresponding delete operation before - * automatically deregistering. - * - * @param listener - An event listener function. - * @returns A {@link Subscription} object that allows the provided listener to be deregistered from future updates. - * @experimental - */ - subscribe(listener: EventCallback>): Subscription; - - /** - * Registers a subscription listener and returns an async iterator that yields - * subscription events each time this instance is updated. - * - * This method functions in the same way as the regular {@link InstanceBase.subscribe | Instance.subscribe()} method, - * but instead returns an async iterator that can be used in a `for await...of` loop for convenience. - * - * @returns An async iterator that yields {@link InstanceSubscriptionEvent} objects. - * @experimental - */ - subscribeIterator(): AsyncIterableIterator>; -} - -/** - * Defines collection methods available on a {@link LiveMapInstance}. - */ -interface LiveMapInstanceCollectionMethods = Record> { - /** - * Returns an iterable of key-value pairs for each entry in the map. - * Each value is represented as an {@link Instance} corresponding to its key. - * - * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental - */ - entries(): IterableIterator<[keyof T, Instance]>; - - /** - * Returns an iterable of keys in the map. - * - * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental - */ - keys(): IterableIterator; - - /** - * Returns an iterable of values in the map. - * Each value is represented as an {@link Instance}. - * - * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental - */ - values(): IterableIterator>; - - /** - * Returns the number of entries in the map. - * - * If the underlying instance at runtime is not a map, returns `undefined`. - * - * @experimental - */ - size(): number | undefined; -} - -/** - * LiveMapInstance represents an Instance of a LiveMap object. - * The type parameter T describes the expected structure of the map's entries. - */ -export interface LiveMapInstance = Record> - extends InstanceBase>, - LiveMapInstanceCollectionMethods, - LiveMapOperations { - /** - * Returns the value associated with a given key as an {@link Instance}. - * - * If the associated value is a primitive, returns a {@link PrimitiveInstance} - * that serves as a snapshot of the primitive value and does not reflect subsequent - * changes to the value at that key. - * - * Returns `undefined` if the key doesn't exist in the map, if the referenced {@link LiveObject} has been deleted, - * or if this map object itself has been deleted. - * - * @param key - The key to retrieve the value for. - * @returns An {@link Instance} representing a {@link LiveObject}, a primitive type (string, number, boolean, JSON-serializable object or array, or binary data) or `undefined` if the key doesn't exist in a map or the referenced {@link LiveObject} has been deleted. Always `undefined` if this map object is deleted. - * @experimental - */ - get(key: K): Instance | undefined; - - /** - * Get an in-memory JavaScript object representation of the map instance. - * 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`. - * - * Use {@link LiveMapInstance.compactJson | compactJson()} for a JSON-serializable representation. - * - * @experimental - */ - compact(): CompactedValue> | undefined; - - /** - * Get a JSON-serializable representation of the map instance. - * Binary values are converted to base64-encoded strings. - * Cyclic references are represented as `{ objectId: string }` instead of in-memory pointers, - * making the result safe to pass to `JSON.stringify()`. - * - * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * Use {@link LiveMapInstance.compact | compact()} for an in-memory representation. - * - * @experimental - */ - compactJson(): CompactedJsonValue> | undefined; -} - -/** - * LiveCounterInstance represents an Instance of a LiveCounter object. - */ -export interface LiveCounterInstance extends InstanceBase, LiveCounterOperations { - /** - * Get the current value of the counter instance. - * If the underlying instance at runtime is not a counter, returns `undefined`. - * - * @experimental - */ - value(): number | undefined; - - /** - * Get a number representation of the counter instance. - * This is an alias for calling {@link LiveCounterInstance.value | value()}. - * - * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * @experimental - */ - compact(): CompactedValue | undefined; - - /** - * Get a number representation of the counter instance. - * This is an alias for calling {@link LiveCounterInstance.value | value()}. - * - * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * @experimental - */ - compactJson(): CompactedJsonValue | undefined; -} - -/** - * PrimitiveInstance represents a snapshot of a primitive value (string, number, boolean, JSON-serializable object or array, or binary data) - * that was stored at a key within a collection type. - */ -export interface PrimitiveInstance { - /** - * Get the primitive value represented by this instance. - * This reflects the value at the corresponding key in the collection at the time this instance was obtained. - * - * If the underlying instance at runtime is not a primitive value, returns `undefined`. - * - * @experimental - */ - value(): T | undefined; - - /** - * Get a JavaScript object representation of the primitive value. - * This is an alias for calling {@link PrimitiveInstance.value | value()}. - * - * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * @experimental - */ - compact(): CompactedValue | undefined; - - /** - * Get a JSON-serializable representation of the primitive value. - * Binary values are converted to base64-encoded strings. - * - * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * @experimental - */ - compactJson(): CompactedJsonValue | undefined; -} - -/** - * AnyInstanceCollectionMethods defines all possible methods available on an Instance - * for the underlying collection types. - */ -interface AnyInstanceCollectionMethods { - // LiveMap collection methods - - /** - * Returns an iterable of key-value pairs for each entry in the map. - * Each value is represented as an {@link Instance} corresponding to its key. - * - * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental - */ - entries>(): IterableIterator<[keyof T, Instance]>; - - /** - * Returns an iterable of keys in the map. - * - * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental - */ - keys>(): IterableIterator; - - /** - * Returns an iterable of values in the map. - * Each value is represented as a {@link Instance}. - * - * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental - */ - values>(): IterableIterator>; - - /** - * Returns the number of entries in the map. - * - * If the underlying instance at runtime is not a map, returns `undefined`. - * - * @experimental - */ - size(): number | undefined; -} - -/** - * Represents an {@link Instance} when its underlying type is not known. - * Provides a unified interface that includes all possible methods. - * - * Each method supports type parameters to specify the expected - * underlying type when needed. - */ -export interface AnyInstance extends InstanceBase, AnyInstanceCollectionMethods, AnyOperations { - /** - * Navigate to a child entry within the collection by obtaining the {@link Instance} at that entry. - * The entry in a collection is identified with a string key. - * - * Returns `undefined` if: - * - The underlying instance at runtime is not a collection object. - * - The specified key does not exist in the collection. - * - The referenced {@link LiveObject} has been deleted. - * - This collection object itself has been deleted. - * - * @param key - The key to get the child entry for. - * @returns An {@link Instance} representing either a {@link LiveObject} or a primitive value (string, number, boolean, JSON-serializable object or array, or binary data), or `undefined` if the underlying instance at runtime is not a collection object, the key does not exist, the referenced {@link LiveObject} has been deleted, or this collection object itself has been deleted. - * @experimental - */ - get(key: string): Instance | undefined; - - /** - * Get the current value of the underlying counter or primitive. - * - * If the underlying value is a primitive, this reflects the value at the corresponding key - * in the collection at the time this instance was obtained. - * - * If the underlying instance at runtime is neither a counter nor a primitive value, returns `undefined`. - * - * @experimental - */ - value(): T | undefined; - - /** - * Get an in-memory JavaScript object representation of the object instance. - * For primitive types, this is an alias for calling {@link AnyInstance.value | value()}. - * - * 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`. - * - * Use {@link AnyInstance.compactJson | compactJson()} for a JSON-serializable representation. - * - * @experimental - */ - compact(): CompactedValue | undefined; - - /** - * Get a JSON-serializable representation of the object instance. - * Binary values are converted to base64-encoded strings. - * - * When compacting a {@link LiveMap}, cyclic references are represented as `{ objectId: string }` - * instead of in-memory pointers, making the result safe to pass to `JSON.stringify()`. - * - * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * Use {@link AnyInstance.compact | compact()} for an in-memory representation. - * - * @experimental - */ - compactJson(): CompactedJsonValue | undefined; -} - -/** - * Instance wraps a specific object instance or entry in a specific collection object instance. - * The type parameter specifies the underlying type of the instance, - * and is used to infer the correct set of methods available for that type. - * - * @experimental - */ -export type Instance = [T] extends [LiveMap] - ? LiveMapInstance - : [T] extends [LiveCounter] - ? LiveCounterInstance - : [T] extends [Primitive] - ? PrimitiveInstance - : AnyInstance; - -/** - * The event object passed to a {@link PathObject} subscription listener. - */ -export type PathObjectSubscriptionEvent = { - /** The {@link PathObject} representing the updated path. */ - object: PathObject; - /** The {@link ObjectMessage} that carried the operation that led to the change, if applicable. */ - message?: ObjectMessage; -}; - -/** - * Options that can be provided to {@link PathObjectBase.subscribe | PathObject.subscribe}. - */ -export interface PathObjectSubscriptionOptions { - /** - * The number of levels deep to observe changes in nested children. - * - * - If `undefined` (default), there is no depth limit, and changes at any depth - * within nested children will be observed. - * - A depth of `1` (the minimum) means that only changes to the object at the subscribed path - * itself will be observed, not changes to its children. - */ - depth?: number; -} - -/** - * The event object passed to an {@link Instance} subscription listener. - */ -export type InstanceSubscriptionEvent = { - /** The {@link Instance} representing the updated object. */ - object: Instance; - /** The {@link ObjectMessage} that carried the operation that led to the change, if applicable. */ - message?: ObjectMessage; -}; - -/** - * The namespace containing the different types of object operation actions. - */ -declare namespace ObjectOperationActions { - /** - * Object operation action for a creating a map object. - */ - type MAP_CREATE = 'map.create'; - /** - * Object operation action for setting a key pair in a map object. - */ - type MAP_SET = 'map.set'; - /** - * Object operation action for removing a key from a map object. - */ - type MAP_REMOVE = 'map.remove'; - /** - * Object operation action for creating a counter object. - */ - type COUNTER_CREATE = 'counter.create'; - /** - * Object operation action for incrementing a counter object. - */ - type COUNTER_INC = 'counter.inc'; - /** - * Object operation action for deleting an object. - */ - type OBJECT_DELETE = 'object.delete'; -} - -/** - * The possible values of the `action` field of an {@link ObjectOperation}. - */ -export type ObjectOperationAction = - | ObjectOperationActions.MAP_CREATE - | ObjectOperationActions.MAP_SET - | ObjectOperationActions.MAP_REMOVE - | ObjectOperationActions.COUNTER_CREATE - | ObjectOperationActions.COUNTER_INC - | ObjectOperationActions.OBJECT_DELETE; - -/** - * The namespace containing the different types of map object semantics. - */ -declare namespace ObjectsMapSemanticsNamespace { - /** - * Last-write-wins conflict-resolution semantics. - */ - type LWW = 'lww'; -} - -/** - * The possible values of the `semantics` field of an {@link ObjectsMap}. - */ -export type ObjectsMapSemantics = ObjectsMapSemanticsNamespace.LWW; - -/** - * An object message that carried an operation. - */ -export interface ObjectMessage { - /** - * Unique ID assigned by Ably to this object message. - */ - id: string; - /** - * The client ID of the publisher of this object message (if any). - */ - clientId?: string; - /** - * The connection ID of the publisher of this object message (if any). - */ - connectionId?: string; - /** - * Timestamp of when the object message was received by Ably, as milliseconds since the Unix epoch. - */ - timestamp: number; - /** - * The name of the channel the object message was published to. - */ - channel: string; - /** - * Describes an operation that was applied to an object. - */ - operation: ObjectOperation; - /** - * An opaque string that uniquely identifies this object message. - */ - serial?: string; - /** - * A timestamp from the {@link serial} field. - */ - serialTimestamp?: number; - /** - * An opaque string that uniquely identifies the Ably site the object message was published to. - */ - siteCode?: string; - /** - * A JSON object of arbitrary key-value pairs that may contain metadata, and/or ancillary payloads. Valid payloads include `headers`. - */ - extras?: { - /** - * A set of key–value pair headers included with this object message. - */ - headers?: Record; - [key: string]: any; - }; -} - -/** - * An operation that was applied to an object on a channel. - */ -export interface ObjectOperation { - /** The operation action, one of the {@link ObjectOperationAction} enum values. */ - action: ObjectOperationAction; - /** The ID of the object the operation was applied to. */ - objectId: string; - /** The payload for the operation if it is a mutation operation on a map object. */ - mapOp?: ObjectsMapOp; - /** The payload for the operation if it is a mutation operation on a counter object. */ - counterOp?: ObjectsCounterOp; - /** - * The payload for the operation if the action is {@link ObjectOperationActions.MAP_CREATE}. - * Defines the initial value of the map object. - */ - map?: ObjectsMap; - /** - * The payload for the operation if the action is {@link ObjectOperationActions.COUNTER_CREATE}. - * Defines the initial value of the counter object. - */ - counter?: ObjectsCounter; -} - -/** - * Describes an operation that was applied to a map object. - */ -export interface ObjectsMapOp { - /** The key that the operation was applied to. */ - key: string; - /** The data assigned to the key if the operation is {@link ObjectOperationActions.MAP_SET}. */ - data?: ObjectData; -} - -/** - * Describes an operation that was applied to a counter object. - */ -export interface ObjectsCounterOp { - /** The value added to the counter. */ - amount: number; -} - -/** - * Describes the initial value of a map object. - */ -export interface ObjectsMap { - /** The conflict-resolution semantics used by the map object, one of the {@link ObjectsMapSemantics} enum values. */ - semantics?: ObjectsMapSemantics; - /** The map entries, indexed by key. */ - entries?: Record; -} - -/** - * Describes a value at a specific key in a map object. - */ -export interface ObjectsMapEntry { - /** Indicates whether the map entry has been removed. */ - tombstone?: boolean; - /** The {@link ObjectMessage.serial} value of the last operation applied to the map entry. */ - timeserial?: string; - /** A timestamp derived from the {@link timeserial} field. Present only if {@link tombstone} is `true`. */ - serialTimestamp?: number; - /** The value associated with this map entry. */ - data?: ObjectData; -} - -/** - * Describes the initial value of a counter object. - */ -export interface ObjectsCounter { - /** The value of the counter. */ - count?: number; -} - -/** - * Represents a value in an object on a channel. - */ -export interface ObjectData { - /** A reference to another object. */ - objectId?: string; - /** A decoded primitive value. */ - value?: Primitive; -} - -/** - * Enables messages to be published and historic messages to be retrieved for a channel. - */ -export declare interface Channel { - /** - * The channel name. - */ - name: string; - - /** - * A {@link Presence} object. - */ - presence: Presence; - /** - * {@link RestAnnotations} - */ - annotations: RestAnnotations; - /** - * A {@link PushChannel} object. - */ - push: PushChannel; - /** - * Retrieves a {@link PaginatedResult} object, containing an array of historical {@link InboundMessage} objects for the channel. If the channel is configured to persist messages, then messages can be retrieved from history for up to 72 hours in the past. If not, messages can only be retrieved from history for up to two minutes in the past. - * - * @param params - A set of parameters which are used to specify which messages should be retrieved. - * @returns A promise which, upon success, will be fulfilled with a {@link PaginatedResult} object containing an array of {@link InboundMessage} objects. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. - */ - history(params?: RestHistoryParams): Promise>; - /** - * Publishes an array of messages to the channel. - * - * @param messages - An array of {@link Message} objects. - * @param options - Optional parameters, such as [`quickAck`](https://faqs.ably.com/why-are-some-rest-publishes-on-a-channel-slow-and-then-typically-faster-on-subsequent-publishes) sent as part of the query string. - * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - */ - publish(messages: Message[], options?: PublishOptions): Promise; - /** - * Publishes a message to the channel. - * - * @param message - A {@link Message} object. - * @param options - Optional parameters, such as [`quickAck`](https://faqs.ably.com/why-are-some-rest-publishes-on-a-channel-slow-and-then-typically-faster-on-subsequent-publishes) sent as part of the query string. - * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - */ - publish(message: Message, options?: PublishOptions): Promise; - /** - * Publishes a single message to the channel with the given event name and payload. - * - * @param name - The name of the message. - * @param data - The payload of the message. - * @param options - Optional parameters, such as [`quickAck`](https://faqs.ably.com/why-are-some-rest-publishes-on-a-channel-slow-and-then-typically-faster-on-subsequent-publishes) sent as part of the query string. - * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - */ - publish(name: string, data: any, options?: PublishOptions): Promise; - /** - * Retrieves a {@link ChannelDetails} object for the channel, which includes status and occupancy metrics. - * - * @returns A promise which, upon success, will be fulfilled a {@link ChannelDetails} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. - */ - status(): Promise; - /** - * Retrieves the latest version of a specific message by its serial identifier. - * - * @param serialOrMessage - Either the serial identifier string of the message to retrieve, or a {@link Message} object containing a populated `serial` field. - * @returns A promise which, upon success, will be fulfilled with a {@link Message} object representing the latest version of the message. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. - */ - getMessage(serialOrMessage: string | Message): Promise; - /** - * Publishes an update to an existing message with patch semantics. Non-null `name`, `data`, and `extras` fields in the provided message will replace the corresponding fields in the existing message, while null fields will be left unchanged. - * - * @param message - A {@link Message} object containing a populated `serial` field and the fields to update. - * @param operation - An optional {@link MessageOperation} object containing metadata about the update operation. - * @param params - Optional parameters sent as part of the query string. - * @returns A promise which on success will be fulfilled, and on failure, rejected with an {@link ErrorInfo} object which explains the error. - */ - updateMessage(message: Message, operation?: MessageOperation, params?: Record): Promise; - /** - * Marks a message as deleted by publishing an update with an action of `MESSAGE_DELETE`. This does not remove the message from the server, and the full message history remains accessible. Uses patch semantics: non-null `name`, `data`, and `extras` fields in the provided message will replace the corresponding fields in the existing message, while null fields will be left unchanged (meaning that if you for example want the `MESSAGE_DELETE` to have an empty data, you should explicitly set the `data` to an empty object). - * - * @param message - A {@link Message} object containing a populated `serial` field. - * @param operation - An optional {@link MessageOperation} object containing metadata about the delete operation. - * @param params - Optional parameters sent as part of the query string. - * @returns A promise which on success will be fulfilled, and on failure, rejected with an {@link ErrorInfo} object which explains the error. - */ - deleteMessage(message: Message, operation?: MessageOperation, params?: Record): Promise; - /** - * Retrieves all historical versions of a specific message, ordered by version. This includes the original message and all subsequent updates or delete operations. - * - * @param serialOrMessage - Either the serial identifier string of the message whose versions are to be retrieved, or a {@link Message} object containing a populated `serial` field. - * @param params - Optional parameters sent as part of the query string. - * @returns A promise which, upon success, will be fulfilled with a {@link PaginatedResult} object containing an array of {@link Message} objects representing all versions of the message. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. - */ - getMessageVersions( - serialOrMessage: string | Message, - params?: Record, - ): Promise>; -} - -/** - * Functionality for annotating messages with small pieces of data, such as emoji - * reactions, that the server will roll up into the message as a summary. - */ -export declare interface RestAnnotations { - /** - * Publish a new annotation for a message. - * - * @param message - The message to annotate. - * @param annotation - The annotation to publish. (Must include at least the `type`. - * Assumed to be an annotation.create if no action is specified) - */ - publish(message: Message, annotation: OutboundAnnotation): Promise; - /** - * Publish a new annotation for a message (alternative form where you only have the - * serial of the message, not a complete Message object) - * - * @param messageSerial - The serial field of the message to annotate. - * @param annotation - The annotation to publish. (Must include at least the `type`. - * Assumed to be an annotation.create if no action is specified) - */ - publish(messageSerial: string, annotation: OutboundAnnotation): Promise; - /** - * Get all annotations for a given message (as a paginated result) - * - * @param message - The message to get annotations for. - * @param params - Restrictions on which annotations to get (in particular a limit) - */ - get(message: Message, params: GetAnnotationsParams | null): Promise>; - /** - * Get all annotations for a given message (as a paginated result) (alternative form - * where you only have the serial of the message, not a complete Message object) - * - * @param messageSerial - The `serial` of the message to get annotations for. - * @param params - Restrictions on which annotations to get (in particular a limit) - */ - get(messageSerial: string, params: GetAnnotationsParams | null): Promise>; -} - -/** - * Enables messages to be published and subscribed to. Also enables historic messages to be retrieved and provides access to the {@link RealtimePresence} object of a channel. - */ -export declare interface RealtimeChannel extends EventEmitter { - /** - * The channel name. - */ - readonly name: string; - /** - * An {@link ErrorInfo} object describing the last error which occurred on the channel, if any. - */ - errorReason: ErrorInfo; - /** - * The current {@link ChannelState} of the channel. - */ - readonly state: ChannelState; - /** - * Optional [channel parameters](https://ably.com/docs/realtime/channels/channel-parameters/overview) that configure the behavior of the channel. - */ - params: ChannelParams; - /** - * An array of {@link ResolvedChannelMode} objects. - */ - modes: ResolvedChannelMode[]; - /** - * Deregisters the given listener for the specified event name. This removes an earlier event-specific subscription. - * - * @param event - The event name. - * @param listener - An event listener function. - */ - unsubscribe(event: string, listener: messageCallback): void; - /** - * Deregisters the given listener from all event names in the array. - * - * @param events - An array of event names. - * @param listener - An event listener function. - */ - unsubscribe(events: Array, listener: messageCallback): void; - /** - * Deregisters all listeners for the given event name. - * - * @param event - The event name. - */ - unsubscribe(event: string): void; - /** - * Deregisters all listeners for all event names in the array. - * - * @param events - An array of event names. - */ - unsubscribe(events: Array): void; - /** - * Deregisters all listeners to messages on this channel that match the supplied filter. - * - * @param filter - A {@link MessageFilter}. - * @param listener - An event listener function. - */ - unsubscribe(filter: MessageFilter, listener?: messageCallback): void; + unsubscribe(filter: MessageFilter, listener?: messageCallback): void; /** * Deregisters the given listener (for any/all event names). This removes an earlier subscription. * @@ -4288,10 +2500,6 @@ export declare interface RealtimeChannel extends EventEmitter = [T][T extends any ? 0 : never]; + +/** + * The `ObjectsEvents` namespace describes the possible values of the {@link ObjectsEvent} type. + */ +declare namespace ObjectsEvents { + /** + * The local copy of Objects on a channel is currently being synchronized with the Ably service. + */ + type SYNCING = 'syncing'; + /** + * The local copy of Objects on a channel has been synchronized with the Ably service. + */ + type SYNCED = 'synced'; +} + +/** + * Describes the events emitted by a {@link RealtimeObject} object. + */ +export type ObjectsEvent = ObjectsEvents.SYNCED | ObjectsEvents.SYNCING; + +/** + * The callback used for the events emitted by {@link RealtimeObject}. + */ +export type ObjectsEventCallback = () => void; + +/** + * A function passed to the {@link BatchOperations.batch | batch} method to group multiple Objects operations into a single channel message. + * + * The function must be synchronous. + * + * @param ctx - The {@link BatchContext} used to group operations together. + */ +export type BatchFunction = (ctx: BatchContext) => void; + +/** + * Enables the Objects to be read, modified and subscribed to for a channel. + */ +export declare interface RealtimeObject { + /** + * Retrieves a {@link PathObject} for the object on a channel. + * + * A type parameter can be provided to describe the structure of the Objects on the channel. + * + * Example: + * + * ```typescript + * import { LiveCounter } from 'ably/objects'; + * + * type MyObject = { + * myTypedCounter: LiveCounter; + * }; + * + * const myTypedObject = await channel.object.get(); + * ``` + * + * @returns A promise which, upon success, will be fulfilled with a {@link PathObject}. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + * @experimental + */ + get>(): Promise>>; + + /** + * Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. + * + * @param event - The named event to listen for. + * @param callback - The event listener. + * @returns A {@link StatusSubscription} object that allows the provided listener to be deregistered from future updates. + * @experimental + */ + on(event: ObjectsEvent, callback: ObjectsEventCallback): StatusSubscription; + + /** + * Removes all registrations that match both the specified listener and the specified event. + * + * @param event - The named event. + * @param callback - The event listener. + * @experimental + */ + off(event: ObjectsEvent, callback: ObjectsEventCallback): void; + + /** + * Deregisters all registrations, for all events and listeners. + * + * @experimental + */ + offAll(): void; +} + +/** + * Primitive types that can be stored in collection types. + * Includes JSON-serializable data so that maps and lists can hold plain JS values. + */ +export type Primitive = + | string + | number + | boolean + | Buffer + | ArrayBuffer + // JSON-serializable primitive values + | JsonArray + | JsonObject; + +/** + * Represents a JSON-encodable value. + */ +export type Json = JsonScalar | JsonArray | JsonObject; + +/** + * Represents a JSON-encodable scalar value. + */ +export type JsonScalar = null | boolean | number | string; + +/** + * Represents a JSON-encodable array. + */ +export type JsonArray = Json[]; + +/** + * Represents a JSON-encodable object. + */ +export type JsonObject = { [prop: string]: Json | undefined }; + +// Branded interfaces that enables TypeScript to distinguish +// between LiveObject types even when they have identical structure (empty interfaces in this case). +// Enables PathObject to dispatch to correct method sets via conditional types. +/** + * A {@link LiveMap} is a collection type that maps string keys to values, which can be either primitive values or other LiveObjects. + */ +export interface LiveMap<_T extends Record = Record> { + /** LiveMap type symbol */ + [__livetype]: 'LiveMap'; +} + +/** + * A {@link LiveCounter} is a numeric type that supports atomic increment and decrement operations. + */ +export interface LiveCounter { + /** LiveCounter type symbol */ + [__livetype]: 'LiveCounter'; +} + +/** + * Type union that matches any LiveObject type that can be mutated, subscribed to, etc. + */ +export type LiveObject = LiveMap | LiveCounter; + +/** + * Type union that defines the base set of allowed types that can be stored in collection types. + * Describes the set of all possible values that can parameterize PathObject. + * This is the canonical union used when a narrower type cannot be inferred. + */ +export type Value = LiveObject | Primitive; + +/** + * CompactedValue transforms LiveObject types into in-memory JavaScript equivalents. + * LiveMap becomes an object, LiveCounter becomes a number, primitive values remain unchanged. + */ +export type CompactedValue = + // LiveMap types + [T] extends [LiveMap] + ? { [K in keyof U]: CompactedValue } + : [T] extends [LiveMap | undefined] + ? { [K in keyof U]: CompactedValue } | undefined + : // LiveCounter types + [T] extends [LiveCounter] + ? number + : [T] extends [LiveCounter | undefined] + ? number | undefined + : // Other primitive types + [T] extends [Primitive] + ? T + : [T] extends [Primitive | undefined] + ? T + : any; + +/** + * Represents a cyclic object reference in a JSON-serializable format. + */ +export interface ObjectIdReference { + /** The referenced object Id. */ + objectId: string; +} + +/** + * CompactedJsonValue transforms LiveObject types into JSON-serializable equivalents. + * LiveMap becomes an object, LiveCounter becomes a number, binary values become base64-encoded strings, + * other primitives remain unchanged. + * + * Additionally, cyclic references are represented as `{ objectId: string }` instead of in-memory pointers to same objects. + */ +export type CompactedJsonValue = + // LiveMap types - note: cyclic references become ObjectIdReference + [T] extends [LiveMap] + ? { [K in keyof U]: CompactedJsonValue } | ObjectIdReference + : [T] extends [LiveMap | undefined] + ? { [K in keyof U]: CompactedJsonValue } | ObjectIdReference | undefined + : // LiveCounter types + [T] extends [LiveCounter] + ? number + : [T] extends [LiveCounter | undefined] + ? number | undefined + : // Binary types (converted to base64 strings) + [T] extends [ArrayBuffer] + ? string + : [T] extends [ArrayBuffer | undefined] + ? string | undefined + : [T] extends [ArrayBufferView] + ? string + : [T] extends [ArrayBufferView | undefined] + ? string | undefined + : // Other primitive types + [T] extends [Primitive] + ? T + : [T] extends [Primitive | undefined] + ? T + : any; + +/** + * PathObjectBase defines the set of common methods on a PathObject + * that are present regardless of the underlying type. + */ +interface PathObjectBase { + /** + * Get the fully-qualified path string for this PathObject. + * + * Path segments with dots in them are escaped with a backslash. + * For example, a path with segments `['a', 'b.c', 'd']` will be represented as `a.b\.c.d`. + * + * @experimental + */ + path(): string; + + /** + * Registers a listener that is called each time the object or a primitive value at this path is updated. + * + * The provided listener receives a {@link PathObject} representing the updated path, + * and, if applicable, an {@link ObjectMessage} that carried the operation that led to the change. + * + * By default, subscriptions observe nested changes, but you can configure the observation depth + * using the `options` parameter. + * + * A PathObject subscription observes whichever value currently exists at this path. + * The subscription remains active even if the path temporarily does not resolve to any value + * (for example, if an entry is removed from a map). If the object instance at this path changes, + * the subscription automatically switches to observe the new instance and stops observing the old one. + * + * @param listener - An event listener function. + * @param options - Optional subscription configuration. + * @returns A {@link Subscription} object that allows the provided listener to be deregistered from future updates. + * @experimental + */ + subscribe( + listener: EventCallback, + options?: PathObjectSubscriptionOptions, + ): Subscription; + + /** + * Registers a subscription listener and returns an async iterator that yields + * subscription events each time the object or a primitive value at this path is updated. + * + * This method functions in the same way as the regular {@link PathObjectBase.subscribe | PathObject.subscribe()} method, + * but instead returns an async iterator that can be used in a `for await...of` loop for convenience. + * + * @param options - Optional subscription configuration. + * @returns An async iterator that yields {@link PathObjectSubscriptionEvent} objects. + * @experimental + */ + subscribeIterator(options?: PathObjectSubscriptionOptions): AsyncIterableIterator; +} + +/** + * PathObjectCollectionMethods defines the set of common methods on a PathObject + * that are present for any collection type, regardless of the specific underlying type. + */ +interface PathObjectCollectionMethods { + /** + * Collection types support obtaining a PathObject with a fully-qualified string path, + * which is evaluated from the current path. + * Using this method loses rich compile-time type information. + * + * @param path - A fully-qualified path string to navigate to, relative to the current path. + * @returns A {@link PathObject} for the specified path. + * @experimental + */ + at(path: string): PathObject; +} + +/** + * Defines collection methods available on a {@link LiveMapPathObject}. + */ +interface LiveMapPathObjectCollectionMethods = Record> { + /** + * Returns an iterable of key-value pairs for each entry in the map at this path. + * Each value is represented as a {@link PathObject} corresponding to its key. + * + * If the path does not resolve to a map object, returns an empty iterator. + * + * @experimental + */ + entries(): IterableIterator<[keyof T, PathObject]>; + + /** + * Returns an iterable of keys in the map at this path. + * + * If the path does not resolve to a map object, returns an empty iterator. + * + * @experimental + */ + keys(): IterableIterator; + + /** + * Returns an iterable of values in the map at this path. + * Each value is represented as a {@link PathObject}. + * + * If the path does not resolve to a map object, returns an empty iterator. + * + * @experimental + */ + values(): IterableIterator>; + + /** + * Returns the number of entries in the map at this path. + * + * If the path does not resolve to a map object, returns `undefined`. + * + * @experimental + */ + size(): number | undefined; +} + +/** + * A PathObject representing a {@link LiveMap} instance at a specific path. + * The type parameter T describes the expected structure of the map's entries. + */ +export interface LiveMapPathObject = Record> + extends PathObjectBase, + PathObjectCollectionMethods, + LiveMapPathObjectCollectionMethods, + LiveMapOperations { + /** + * Navigate to a child path within the map by obtaining a PathObject for that path. + * The next path segment in a LiveMap is identified with a string key. + * + * @param key - A string key for the next path segment within the map. + * @returns A {@link PathObject} for the specified key. + * @experimental + */ + get(key: K): PathObject; + + /** + * Get the specific map instance currently at this path. + * If the path does not resolve to any specific instance, returns `undefined`. + * + * @returns The {@link LiveMapInstance} at this path, or `undefined` if none exists. + * @experimental + */ + instance(): LiveMapInstance | undefined; + + /** + * Get an in-memory JavaScript object representation of the map at this path. + * 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`. + * + * Use {@link LiveMapPathObject.compactJson | compactJson()} for a JSON-serializable representation. + * + * @experimental + */ + compact(): CompactedValue> | undefined; + + /** + * Get a JSON-serializable representation of the map at this path. + * Binary values are converted to base64-encoded strings. + * Cyclic references are represented as `{ objectId: string }` instead of in-memory pointers, + * making the result safe to pass to `JSON.stringify()`. + * + * If the path does not resolve to any specific instance, returns `undefined`. + * + * Use {@link LiveMapPathObject.compact | compact()} for an in-memory representation. + * + * @experimental + */ + compactJson(): CompactedJsonValue> | undefined; +} + +/** + * A PathObject representing a {@link LiveCounter} instance at a specific path. + */ +export interface LiveCounterPathObject extends PathObjectBase, LiveCounterOperations { + /** + * Get the current value of the counter instance currently at this path. + * If the path does not resolve to any specific instance, returns `undefined`. + * + * @experimental + */ + value(): number | undefined; + + /** + * Get the specific counter instance currently at this path. + * If the path does not resolve to any specific instance, returns `undefined`. + * + * @returns The {@link LiveCounterInstance} at this path, or `undefined` if none exists. + * @experimental + */ + instance(): LiveCounterInstance | undefined; + + /** + * Get a number representation of the counter at this path. + * This is an alias for calling {@link LiveCounterPathObject.value | value()}. + * + * If the path does not resolve to any specific instance, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue | undefined; + + /** + * Get a number representation of the counter at this path. + * This is an alias for calling {@link LiveCounterPathObject.value | value()}. + * + * If the path does not resolve to any specific instance, returns `undefined`. + * + * @experimental + */ + compactJson(): CompactedJsonValue | undefined; +} + +/** + * A PathObject representing a primitive value at a specific path. + */ +export interface PrimitivePathObject extends PathObjectBase { + /** + * Get the current value of the primitive currently at this path. + * If the path does not resolve to any specific entry, returns `undefined`. + * + * @experimental + */ + value(): T | undefined; + + /** + * Get a JavaScript object representation of the primitive value at this path. + * This is an alias for calling {@link PrimitivePathObject.value | value()}. + * + * If the path does not resolve to any specific entry, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue | undefined; + + /** + * Get a JSON-serializable representation of the primitive value at this path. + * Binary values are converted to base64-encoded strings. + * + * If the path does not resolve to any specific entry, returns `undefined`. + * + * @experimental + */ + compactJson(): CompactedJsonValue | undefined; +} + +/** + * AnyPathObjectCollectionMethods defines all possible methods available on a PathObject + * for the underlying collection types. + */ +interface AnyPathObjectCollectionMethods { + // LiveMap collection methods + + /** + * Returns an iterable of key-value pairs for each entry in the map, if the path resolves to a {@link LiveMap}. + * Each value is represented as a {@link PathObject} corresponding to its key. + * + * If the path does not resolve to a map object, returns an empty iterator. + * + * @experimental + */ + entries>(): IterableIterator<[keyof T, PathObject]>; + + /** + * Returns an iterable of keys in the map, if the path resolves to a {@link LiveMap}. + * + * If the path does not resolve to a map object, returns an empty iterator. + * + * @experimental + */ + keys>(): IterableIterator; + + /** + * Returns an iterable of values in the map, if the path resolves to a {@link LiveMap}. + * Each value is represented as a {@link PathObject}. + * + * If the path does not resolve to a map object, returns an empty iterator. + * + * @experimental + */ + values>(): IterableIterator>; + + /** + * Returns the number of entries in the map, if the path resolves to a {@link LiveMap}. + * + * If the path does not resolve to a map object, returns `undefined`. + * + * @experimental + */ + size(): number | undefined; +} + +/** + * Represents a {@link PathObject} when its underlying type is not known. + * Provides a unified interface that includes all possible methods. + * + * Each method supports type parameters to specify the expected + * underlying type when needed. + */ +export interface AnyPathObject + extends PathObjectBase, + PathObjectCollectionMethods, + AnyPathObjectCollectionMethods, + AnyOperations { + /** + * Navigate to a child path within the collection by obtaining a PathObject for that path. + * The next path segment in a collection is identified with a string key. + * + * @param key - A string key for the next path segment within the collection. + * @returns A {@link PathObject} for the specified key. + * @experimental + */ + get(key: string): PathObject; + + /** + * Get the current value of the LiveCounter or primitive currently at this path. + * If the path does not resolve to any specific entry, returns `undefined`. + * + * @experimental + */ + value(): T | undefined; + + /** + * Get the specific object instance currently at this path. + * If the path does not resolve to any specific instance, returns `undefined`. + * + * @returns The object instance at this path, or `undefined` if none exists. + * @experimental + */ + instance(): Instance | undefined; + + /** + * Get an in-memory JavaScript object representation of the object at this path. + * For primitive types, this is an alias for calling {@link AnyPathObject.value | value()}. + * + * 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`. + * + * Use {@link AnyPathObject.compactJson | compactJson()} for a JSON-serializable representation. + * + * @experimental + */ + compact(): CompactedValue | undefined; + + /** + * Get a JSON-serializable representation of the object at this path. + * Binary values are converted to base64-encoded strings. + * + * When compacting a {@link LiveMap}, cyclic references are represented as `{ objectId: string }` + * instead of in-memory pointers, making the result safe to pass to `JSON.stringify()`. + * + * If the path does not resolve to any specific entry, returns `undefined`. + * + * Use {@link AnyPathObject.compact | compact()} for an in-memory representation. + * + * @experimental + */ + compactJson(): CompactedJsonValue | undefined; +} + +/** + * PathObject wraps a reference to a path starting from the entrypoint object on a channel. + * The type parameter specifies the underlying type defined at that path, + * and is used to infer the correct set of methods available for that type. + * + * @experimental + */ +export type PathObject = [T] extends [LiveMap] + ? LiveMapPathObject + : [T] extends [LiveCounter] + ? LiveCounterPathObject + : [T] extends [Primitive] + ? PrimitivePathObject + : AnyPathObject; + +/** + * BatchContextBase defines the set of common methods on a BatchContext + * that are present regardless of the underlying type. + */ +interface BatchContextBase { + /** + * Get the object ID of the underlying instance. + * + * If the underlying instance at runtime is not a {@link LiveObject}, returns `undefined`. + * + * @experimental + */ + readonly id: string | undefined; +} + +/** + * Defines collection methods available on a {@link LiveMapBatchContext}. + */ +interface LiveMapBatchContextCollectionMethods = Record> { + /** + * Returns an iterable of key-value pairs for each entry in the map. + * Each value is represented as a {@link BatchContext} corresponding to its key. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + entries(): IterableIterator<[keyof T, BatchContext]>; + + /** + * Returns an iterable of keys in the map. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + keys(): IterableIterator; + + /** + * Returns an iterable of values in the map. + * Each value is represented as a {@link BatchContext}. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + values(): IterableIterator>; + + /** + * Returns the number of entries in the map. + * + * If the underlying instance at runtime is not a map, returns `undefined`. + * + * @experimental + */ + size(): number | undefined; +} + +/** + * LiveMapBatchContext is a batch context wrapper for a LiveMap object. + * The type parameter T describes the expected structure of the map's entries. + */ +export interface LiveMapBatchContext = Record> + extends BatchContextBase, + BatchContextLiveMapOperations, + LiveMapBatchContextCollectionMethods { + /** + * Returns the value associated with a given key as a {@link BatchContext}. + * + * Returns `undefined` if the key doesn't exist in the map, if the referenced {@link LiveObject} has been deleted, + * or if this map object itself has been deleted. + * + * @param key - The key to retrieve the value for. + * @returns A {@link BatchContext} representing a {@link LiveObject}, a primitive type (string, number, boolean, JSON-serializable object or array, or binary data) or `undefined` if the key doesn't exist in a map or the referenced {@link LiveObject} has been deleted. Always `undefined` if this map object is deleted. + * @experimental + */ + get(key: K): BatchContext | undefined; + + /** + * Get an in-memory JavaScript object representation of the map instance. + * 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`. + * + * Use {@link LiveMapBatchContext.compactJson | compactJson()} for a JSON-serializable representation. + * + * @experimental + */ + compact(): CompactedValue> | undefined; + + /** + * Get a JSON-serializable representation of the map instance. + * Binary values are converted to base64-encoded strings. + * Cyclic references are represented as `{ objectId: string }` instead of in-memory pointers, + * making the result safe to pass to `JSON.stringify()`. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * Use {@link LiveMapBatchContext.compact | compact()} for an in-memory representation. + * + * @experimental + */ + compactJson(): CompactedJsonValue> | undefined; +} + +/** + * LiveCounterBatchContext is a batch context wrapper for a LiveCounter object. + */ +export interface LiveCounterBatchContext extends BatchContextBase, BatchContextLiveCounterOperations { + /** + * Get the current value of the counter instance. + * If the underlying instance at runtime is not a counter, returns `undefined`. + * + * @experimental + */ + value(): number | undefined; + + /** + * Get a number representation of the counter instance. + * This is an alias for calling {@link LiveCounterBatchContext.value | value()}. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue | undefined; + + /** + * Get a number representation of the counter instance. + * This is an alias for calling {@link LiveCounterBatchContext.value | value()}. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compactJson(): CompactedJsonValue | undefined; +} + +/** + * PrimitiveBatchContext is a batch context wrapper for a primitive value (string, number, boolean, JSON-serializable object or array, or binary data). + */ +export interface PrimitiveBatchContext { + /** + * Get the underlying primitive value. + * If the underlying instance at runtime is not a primitive value, returns `undefined`. + * + * @experimental + */ + value(): T | undefined; + + /** + * Get a JavaScript object representation of the primitive value. + * This is an alias for calling {@link PrimitiveBatchContext.value | value()}. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue | undefined; + + /** + * Get a JSON-serializable representation of the primitive value. + * Binary values are converted to base64-encoded strings. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compactJson(): CompactedJsonValue | undefined; +} + +/** + * AnyBatchContextCollectionMethods defines all possible methods available on an BatchContext object + * for the underlying collection types. + */ +interface AnyBatchContextCollectionMethods { + // LiveMap collection methods + + /** + * Returns an iterable of key-value pairs for each entry in the map. + * Each value is represented as an {@link BatchContext} corresponding to its key. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + entries>(): IterableIterator<[keyof T, BatchContext]>; + + /** + * Returns an iterable of keys in the map. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + keys>(): IterableIterator; + + /** + * Returns an iterable of values in the map. + * Each value is represented as a {@link BatchContext}. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + values>(): IterableIterator>; + + /** + * Returns the number of entries in the map. + * + * If the underlying instance at runtime is not a map, returns `undefined`. + * + * @experimental + */ + size(): number | undefined; +} + +/** + * Represents a {@link BatchContext} when its underlying type is not known. + * Provides a unified interface that includes all possible methods. + * + * Each method supports type parameters to specify the expected + * underlying type when needed. + */ +export interface AnyBatchContext extends BatchContextBase, AnyBatchContextCollectionMethods, BatchContextAnyOperations { + /** + * Navigate to a child entry within the collection by obtaining the {@link BatchContext} at that entry. + * The entry in a collection is identified with a string key. + * + * Returns `undefined` if: + * - The underlying instance at runtime is not a collection object. + * - The specified key does not exist in the collection. + * - The referenced {@link LiveObject} has been deleted. + * - This collection object itself has been deleted. + * + * @param key - The key to retrieve the value for. + * @returns A {@link BatchContext} representing either a {@link LiveObject} or a primitive value (string, number, boolean, JSON-serializable object or array, or binary data), or `undefined` if the underlying instance at runtime is not a collection object, the key does not exist, the referenced {@link LiveObject} has been deleted, or this collection object itself has been deleted. + * @experimental + */ + get(key: string): BatchContext | undefined; + + /** + * Get the current value of the underlying counter or primitive. + * + * If the underlying instance at runtime is neither a counter nor a primitive value, returns `undefined`. + * + * @returns The current value of the underlying primitive or counter, or `undefined` if the value cannot be retrieved. + * @experimental + */ + value(): T | undefined; + + /** + * Get an in-memory JavaScript object representation of the object instance. + * For primitive types, this is an alias for calling {@link AnyBatchContext.value | value()}. + * + * 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`. + * + * Use {@link AnyBatchContext.compactJson | compactJson()} for a JSON-serializable representation. + * + * @experimental + */ + compact(): CompactedValue | undefined; + + /** + * Get a JSON-serializable representation of the object instance. + * Binary values are converted to base64-encoded strings. + * + * When compacting a {@link LiveMap}, cyclic references are represented as `{ objectId: string }` + * instead of in-memory pointers, making the result safe to pass to `JSON.stringify()`. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * Use {@link AnyBatchContext.compact | compact()} for an in-memory representation. + * + * @experimental + */ + compactJson(): CompactedJsonValue | undefined; +} + +/** + * BatchContext wraps a specific object instance or entry in a specific collection + * object instance and provides synchronous operation methods that can be aggregated + * and applied as a single batch operation. + * + * The type parameter specifies the underlying type of the instance, + * and is used to infer the correct set of methods available for that type. + * + * @experimental + */ +export type BatchContext = [T] extends [LiveMap] + ? LiveMapBatchContext + : [T] extends [LiveCounter] + ? LiveCounterBatchContext + : [T] extends [Primitive] + ? PrimitiveBatchContext + : AnyBatchContext; + +/** + * Defines operations available on {@link LiveMapBatchContext}. + */ +interface BatchContextLiveMapOperations = Record> { + /** + * Adds an operation to the current batch to set a key to a specified value on the underlying + * {@link LiveMapInstance}. All queued operations are sent together in a single message once the + * batch function completes. + * + * If the underlying instance at runtime is not a map, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to set the value for. + * @param value - The value to assign to the key. + * @experimental + */ + set(key: K, value: T[K]): void; + + /** + * Adds an operation to the current batch to remove a key from the underlying + * {@link LiveMapInstance}. All queued operations are sent together in a single message once the + * batch function completes. + * + * If the underlying instance at runtime is not a map, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to remove. + * @experimental + */ + remove(key: keyof T & string): void; +} + +/** + * Defines operations available on {@link LiveCounterBatchContext}. + */ +interface BatchContextLiveCounterOperations { + /** + * Adds an operation to the current batch to increment the value of the underlying + * {@link LiveCounterInstance}. All queued operations are sent together in a single message once the + * batch function completes. + * + * If the underlying instance at runtime is not a counter, this method throws an error. + * + * This does not modify the underlying data of the counter. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. + * @experimental + */ + increment(amount?: number): void; + + /** + * An alias for calling {@link BatchContextLiveCounterOperations.increment | increment(-amount)} + * + * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. + * @experimental + */ + decrement(amount?: number): void; +} + +/** + * Defines all possible operations available on {@link BatchContext} objects. + */ +interface BatchContextAnyOperations { + // LiveMap operations + + /** + * Adds an operation to the current batch to set a key to a specified value on the underlying + * {@link LiveMapInstance}. All queued operations are sent together in a single message once the + * batch function completes. + * + * If the underlying instance at runtime is not a map, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to set the value for. + * @param value - The value to assign to the key. + * @experimental + */ + set = Record>(key: keyof T & string, value: T[keyof T]): void; + + /** + * Adds an operation to the current batch to remove a key from the underlying + * {@link LiveMapInstance}. All queued operations are sent together in a single message once the + * batch function completes. + * + * If the underlying instance at runtime is not a map, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to remove. + * @experimental + */ + remove = Record>(key: keyof T & string): void; + + // LiveCounter operations + + /** + * Adds an operation to the current batch to increment the value of the underlying + * {@link LiveCounterInstance}. All queued operations are sent together in a single message once the + * batch function completes. + * + * If the underlying instance at runtime is not a counter, this method throws an error. + * + * This does not modify the underlying data of the counter. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. + * @experimental + */ + increment(amount?: number): void; + + /** + * An alias for calling {@link BatchContextAnyOperations.increment | increment(-amount)} + * + * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. + * @experimental + */ + decrement(amount?: number): void; +} + +/** + * Defines batch operations available on {@link LiveObject | LiveObjects}. + */ +interface BatchOperations { + /** + * Batch multiple operations together using a batch context, which + * wraps the underlying {@link PathObject} or {@link Instance} from which the batch was called. + * The batch context always contains a resolved instance, even when called from a {@link PathObject}. + * If an instance cannot be resolved from the referenced path, or if the instance is not a {@link LiveObject}, + * this method throws an error. + * + * Batching enables you to group multiple operations together and send them to the Ably service in a single channel message. + * As a result, other clients will receive the changes in a single channel message once the batch function has completed. + * + * The objects' data is not modified inside the batch function. Instead, the objects will be updated + * when the batched operations are applied by the Ably service and echoed back to the client. + * + * @param fn - A synchronous function that receives a {@link BatchContext} used to group operations together. + * @returns A promise which resolves upon success of the batch operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + batch(fn: BatchFunction): Promise; +} + +/** + * Defines operations available on {@link LiveMap} objects. + */ +interface LiveMapOperations = Record> + extends BatchOperations> { + /** + * Sends an operation to the Ably system to set a key to a specified value on a given {@link LiveMapInstance}, + * or on the map instance resolved from the path when using {@link LiveMapPathObject}. + * + * If called via {@link LiveMapInstance} and the underlying instance at runtime is not a map, + * or if called via {@link LiveMapPathObject} and the map instance at the specified path cannot + * be resolved at the time of the call, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to set the value for. + * @param value - The value to assign to the key. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + set(key: K, value: T[K]): Promise; + + /** + * Sends an operation to the Ably system to remove a key from a given {@link LiveMapInstance}, + * or from the map instance resolved from the path when using {@link LiveMapPathObject}. + * + * If called via {@link LiveMapInstance} and the underlying instance at runtime is not a map, + * or if called via {@link LiveMapPathObject} and the map instance at the specified path cannot + * be resolved at the time of the call, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to remove. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + remove(key: keyof T & string): Promise; +} + +/** + * Defines operations available on {@link LiveCounter} objects. + */ +interface LiveCounterOperations extends BatchOperations { + /** + * Sends an operation to the Ably system to increment the value of a given {@link LiveCounterInstance}, + * or of the counter instance resolved from the path when using {@link LiveCounterPathObject}. + * + * If called via {@link LiveCounterInstance} and the underlying instance at runtime is not a counter, + * or if called via {@link LiveCounterPathObject} and the counter instance at the specified path cannot + * be resolved at the time of the call, this method throws an error. + * + * This does not modify the underlying data of the counter. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + increment(amount?: number): Promise; + + /** + * An alias for calling {@link LiveCounterOperations.increment | increment(-amount)} + * + * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + decrement(amount?: number): Promise; +} + +/** + * Defines all possible operations available on {@link LiveObject | LiveObjects}. + */ +interface AnyOperations { + /** + * Batch multiple operations together using a batch context, which + * wraps the underlying {@link PathObject} or {@link Instance} from which the batch was called. + * The batch context always contains a resolved instance, even when called from a {@link PathObject}. + * If an instance cannot be resolved from the referenced path, or if the instance is not a {@link LiveObject}, + * this method throws an error. + * + * Batching enables you to group multiple operations together and send them to the Ably service in a single channel message. + * As a result, other clients will receive the changes in a single channel message once the batch function has completed. + * + * The objects' data is not modified inside the batch function. Instead, the objects will be updated + * when the batched operations are applied by the Ably service and echoed back to the client. + * + * @param fn - A synchronous function that receives a {@link BatchContext} used to group operations together. + * @returns A promise which resolves upon success of the batch operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + batch(fn: BatchFunction): Promise; + + // LiveMap operations + + /** + * Sends an operation to the Ably system to set a key to a specified value on the underlying map when using {@link AnyInstance}, + * or on the map instance resolved from the path when using {@link AnyPathObject}. + * + * If called via {@link AnyInstance} and the underlying instance at runtime is not a map, + * or if called via {@link AnyPathObject} and the map instance at the specified path cannot + * be resolved at the time of the call, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to set the value for. + * @param value - The value to assign to the key. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + set = Record>(key: keyof T & string, value: T[keyof T]): Promise; + + /** + * Sends an operation to the Ably system to remove a key from the underlying map when using {@link AnyInstance}, + * or from the map instance resolved from the path when using {@link AnyPathObject}. + * + * If called via {@link AnyInstance} and the underlying instance at runtime is not a map, + * or if called via {@link AnyPathObject} and the map instance at the specified path cannot + * be resolved at the time of the call, this method throws an error. + * + * This does not modify the underlying data of the map. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param key - The key to remove. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + remove = Record>(key: keyof T & string): Promise; + + // LiveCounter operations + + /** + * Sends an operation to the Ably system to increment the value of the underlying counter when using {@link AnyInstance}, + * or of the counter instance resolved from the path when using {@link AnyPathObject}. + * + * If called via {@link AnyInstance} and the underlying instance at runtime is not a counter, + * or if called via {@link AnyPathObject} and the counter instance at the specified path cannot + * be resolved at the time of the call, this method throws an error. + * + * This does not modify the underlying data of the counter. Instead, the change is applied when + * the published operation is echoed back to the client and applied to the object. + * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. + * + * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + increment(amount?: number): Promise; + + /** + * An alias for calling {@link AnyOperations.increment | increment(-amount)} + * + * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. + * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. + * @experimental + */ + decrement(amount?: number): Promise; +} + +/** + * InstanceBase defines the set of common methods on an Instance + * that are present regardless of the underlying type specified in the type parameter T. + */ +interface InstanceBase { + /** + * Get the object ID of the underlying instance. + * + * If the underlying instance at runtime is not a {@link LiveObject}, returns `undefined`. + * + * @experimental + */ + readonly id: string | undefined; + + /** + * Registers a listener that is called each time this instance is updated. + * + * If the underlying instance at runtime is not a {@link LiveObject}, this method throws an error. + * + * The provided listener receives an {@link Instance} representing the updated object, + * and, if applicable, an {@link ObjectMessage} that carried the operation that led to the change. + * + * Instance subscriptions track a specific object instance regardless of its location. + * The subscription follows the instance if it is moved within the broader structure + * (for example, between map entries). + * + * If the instance is deleted from the channel object entirely (i.e., tombstoned), + * the listener is called with the corresponding delete operation before + * automatically deregistering. + * + * @param listener - An event listener function. + * @returns A {@link Subscription} object that allows the provided listener to be deregistered from future updates. + * @experimental + */ + subscribe(listener: EventCallback>): Subscription; + + /** + * Registers a subscription listener and returns an async iterator that yields + * subscription events each time this instance is updated. + * + * This method functions in the same way as the regular {@link InstanceBase.subscribe | Instance.subscribe()} method, + * but instead returns an async iterator that can be used in a `for await...of` loop for convenience. + * + * @returns An async iterator that yields {@link InstanceSubscriptionEvent} objects. + * @experimental + */ + subscribeIterator(): AsyncIterableIterator>; +} + +/** + * Defines collection methods available on a {@link LiveMapInstance}. + */ +interface LiveMapInstanceCollectionMethods = Record> { + /** + * Returns an iterable of key-value pairs for each entry in the map. + * Each value is represented as an {@link Instance} corresponding to its key. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + entries(): IterableIterator<[keyof T, Instance]>; + + /** + * Returns an iterable of keys in the map. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + keys(): IterableIterator; + + /** + * Returns an iterable of values in the map. + * Each value is represented as an {@link Instance}. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + values(): IterableIterator>; + + /** + * Returns the number of entries in the map. + * + * If the underlying instance at runtime is not a map, returns `undefined`. + * + * @experimental + */ + size(): number | undefined; +} + +/** + * LiveMapInstance represents an Instance of a LiveMap object. + * The type parameter T describes the expected structure of the map's entries. + */ +export interface LiveMapInstance = Record> + extends InstanceBase>, + LiveMapInstanceCollectionMethods, + LiveMapOperations { + /** + * Returns the value associated with a given key as an {@link Instance}. + * + * If the associated value is a primitive, returns a {@link PrimitiveInstance} + * that serves as a snapshot of the primitive value and does not reflect subsequent + * changes to the value at that key. + * + * Returns `undefined` if the key doesn't exist in the map, if the referenced {@link LiveObject} has been deleted, + * or if this map object itself has been deleted. + * + * @param key - The key to retrieve the value for. + * @returns An {@link Instance} representing a {@link LiveObject}, a primitive type (string, number, boolean, JSON-serializable object or array, or binary data) or `undefined` if the key doesn't exist in a map or the referenced {@link LiveObject} has been deleted. Always `undefined` if this map object is deleted. + * @experimental + */ + get(key: K): Instance | undefined; + + /** + * Get an in-memory JavaScript object representation of the map instance. + * 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`. + * + * Use {@link LiveMapInstance.compactJson | compactJson()} for a JSON-serializable representation. + * + * @experimental + */ + compact(): CompactedValue> | undefined; + + /** + * Get a JSON-serializable representation of the map instance. + * Binary values are converted to base64-encoded strings. + * Cyclic references are represented as `{ objectId: string }` instead of in-memory pointers, + * making the result safe to pass to `JSON.stringify()`. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * Use {@link LiveMapInstance.compact | compact()} for an in-memory representation. + * + * @experimental + */ + compactJson(): CompactedJsonValue> | undefined; +} + +/** + * LiveCounterInstance represents an Instance of a LiveCounter object. + */ +export interface LiveCounterInstance extends InstanceBase, LiveCounterOperations { + /** + * Get the current value of the counter instance. + * If the underlying instance at runtime is not a counter, returns `undefined`. + * + * @experimental + */ + value(): number | undefined; + + /** + * Get a number representation of the counter instance. + * This is an alias for calling {@link LiveCounterInstance.value | value()}. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue | undefined; + + /** + * Get a number representation of the counter instance. + * This is an alias for calling {@link LiveCounterInstance.value | value()}. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compactJson(): CompactedJsonValue | undefined; +} + +/** + * PrimitiveInstance represents a snapshot of a primitive value (string, number, boolean, JSON-serializable object or array, or binary data) + * that was stored at a key within a collection type. + */ +export interface PrimitiveInstance { + /** + * Get the primitive value represented by this instance. + * This reflects the value at the corresponding key in the collection at the time this instance was obtained. + * + * If the underlying instance at runtime is not a primitive value, returns `undefined`. + * + * @experimental + */ + value(): T | undefined; + + /** + * Get a JavaScript object representation of the primitive value. + * This is an alias for calling {@link PrimitiveInstance.value | value()}. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compact(): CompactedValue | undefined; + + /** + * Get a JSON-serializable representation of the primitive value. + * Binary values are converted to base64-encoded strings. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * @experimental + */ + compactJson(): CompactedJsonValue | undefined; +} + +/** + * AnyInstanceCollectionMethods defines all possible methods available on an Instance + * for the underlying collection types. + */ +interface AnyInstanceCollectionMethods { + // LiveMap collection methods + + /** + * Returns an iterable of key-value pairs for each entry in the map. + * Each value is represented as an {@link Instance} corresponding to its key. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + entries>(): IterableIterator<[keyof T, Instance]>; + + /** + * Returns an iterable of keys in the map. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + keys>(): IterableIterator; + + /** + * Returns an iterable of values in the map. + * Each value is represented as a {@link Instance}. + * + * If the underlying instance at runtime is not a map, returns an empty iterator. + * + * @experimental + */ + values>(): IterableIterator>; + + /** + * Returns the number of entries in the map. + * + * If the underlying instance at runtime is not a map, returns `undefined`. + * + * @experimental + */ + size(): number | undefined; +} + +/** + * Represents an {@link Instance} when its underlying type is not known. + * Provides a unified interface that includes all possible methods. * - * This works by leveraging deferred conditional types - the compiler can't - * evaluate the conditional until it knows what T is, which prevents TypeScript - * from digging into the type to find inference candidates. + * Each method supports type parameters to specify the expected + * underlying type when needed. + */ +export interface AnyInstance extends InstanceBase, AnyInstanceCollectionMethods, AnyOperations { + /** + * Navigate to a child entry within the collection by obtaining the {@link Instance} at that entry. + * The entry in a collection is identified with a string key. + * + * Returns `undefined` if: + * - The underlying instance at runtime is not a collection object. + * - The specified key does not exist in the collection. + * - The referenced {@link LiveObject} has been deleted. + * - This collection object itself has been deleted. + * + * @param key - The key to get the child entry for. + * @returns An {@link Instance} representing either a {@link LiveObject} or a primitive value (string, number, boolean, JSON-serializable object or array, or binary data), or `undefined` if the underlying instance at runtime is not a collection object, the key does not exist, the referenced {@link LiveObject} has been deleted, or this collection object itself has been deleted. + * @experimental + */ + get(key: string): Instance | undefined; + + /** + * Get the current value of the underlying counter or primitive. + * + * If the underlying value is a primitive, this reflects the value at the corresponding key + * in the collection at the time this instance was obtained. + * + * If the underlying instance at runtime is neither a counter nor a primitive value, returns `undefined`. + * + * @experimental + */ + value(): T | undefined; + + /** + * Get an in-memory JavaScript object representation of the object instance. + * For primitive types, this is an alias for calling {@link AnyInstance.value | value()}. + * + * 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`. + * + * Use {@link AnyInstance.compactJson | compactJson()} for a JSON-serializable representation. + * + * @experimental + */ + compact(): CompactedValue | undefined; + + /** + * Get a JSON-serializable representation of the object instance. + * Binary values are converted to base64-encoded strings. + * + * When compacting a {@link LiveMap}, cyclic references are represented as `{ objectId: string }` + * instead of in-memory pointers, making the result safe to pass to `JSON.stringify()`. + * + * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. + * + * Use {@link AnyInstance.compact | compact()} for an in-memory representation. + * + * @experimental + */ + compactJson(): CompactedJsonValue | undefined; +} + +/** + * Instance wraps a specific object instance or entry in a specific collection object instance. + * The type parameter specifies the underlying type of the instance, + * and is used to infer the correct set of methods available for that type. * - * See: - * - https://stackoverflow.com/questions/56687668 - * - https://www.typescriptlang.org/docs/handbook/utility-types.html#noinfertype + * @experimental */ -type NoInfer = [T][T extends any ? 0 : never]; +export type Instance = [T] extends [LiveMap] + ? LiveMapInstance + : [T] extends [LiveCounter] + ? LiveCounterInstance + : [T] extends [Primitive] + ? PrimitiveInstance + : AnyInstance; + +/** + * The event object passed to a {@link PathObject} subscription listener. + */ +export type PathObjectSubscriptionEvent = { + /** The {@link PathObject} representing the updated path. */ + object: PathObject; + /** The {@link ObjectMessage} that carried the operation that led to the change, if applicable. */ + message?: ObjectMessage; +}; + +/** + * Options that can be provided to {@link PathObjectBase.subscribe | PathObject.subscribe}. + */ +export interface PathObjectSubscriptionOptions { + /** + * The number of levels deep to observe changes in nested children. + * + * - If `undefined` (default), there is no depth limit, and changes at any depth + * within nested children will be observed. + * - A depth of `1` (the minimum) means that only changes to the object at the subscribed path + * itself will be observed, not changes to its children. + */ + depth?: number; +} + +/** + * The event object passed to an {@link Instance} subscription listener. + */ +export type InstanceSubscriptionEvent = { + /** The {@link Instance} representing the updated object. */ + object: Instance; + /** The {@link ObjectMessage} that carried the operation that led to the change, if applicable. */ + message?: ObjectMessage; +}; + +/** + * The namespace containing the different types of object operation actions. + */ +declare namespace ObjectOperationActions { + /** + * Object operation action for a creating a map object. + */ + type MAP_CREATE = 'map.create'; + /** + * Object operation action for setting a key pair in a map object. + */ + type MAP_SET = 'map.set'; + /** + * Object operation action for removing a key from a map object. + */ + type MAP_REMOVE = 'map.remove'; + /** + * Object operation action for creating a counter object. + */ + type COUNTER_CREATE = 'counter.create'; + /** + * Object operation action for incrementing a counter object. + */ + type COUNTER_INC = 'counter.inc'; + /** + * Object operation action for deleting an object. + */ + type OBJECT_DELETE = 'object.delete'; +} + +/** + * The possible values of the `action` field of an {@link ObjectOperation}. + */ +export type ObjectOperationAction = + | ObjectOperationActions.MAP_CREATE + | ObjectOperationActions.MAP_SET + | ObjectOperationActions.MAP_REMOVE + | ObjectOperationActions.COUNTER_CREATE + | ObjectOperationActions.COUNTER_INC + | ObjectOperationActions.OBJECT_DELETE; + +/** + * The namespace containing the different types of map object semantics. + */ +declare namespace ObjectsMapSemanticsNamespace { + /** + * Last-write-wins conflict-resolution semantics. + */ + type LWW = 'lww'; +} + +/** + * The possible values of the `semantics` field of an {@link ObjectsMap}. + */ +export type ObjectsMapSemantics = ObjectsMapSemanticsNamespace.LWW; + +/** + * An object message that carried an operation. + */ +export interface ObjectMessage { + /** + * Unique ID assigned by Ably to this object message. + */ + id: string; + /** + * The client ID of the publisher of this object message (if any). + */ + clientId?: string; + /** + * The connection ID of the publisher of this object message (if any). + */ + connectionId?: string; + /** + * Timestamp of when the object message was received by Ably, as milliseconds since the Unix epoch. + */ + timestamp: number; + /** + * The name of the channel the object message was published to. + */ + channel: string; + /** + * Describes an operation that was applied to an object. + */ + operation: ObjectOperation; + /** + * An opaque string that uniquely identifies this object message. + */ + serial?: string; + /** + * A timestamp from the {@link serial} field. + */ + serialTimestamp?: number; + /** + * An opaque string that uniquely identifies the Ably site the object message was published to. + */ + siteCode?: string; + /** + * A JSON object of arbitrary key-value pairs that may contain metadata, and/or ancillary payloads. Valid payloads include `headers`. + */ + extras?: { + /** + * A set of key–value pair headers included with this object message. + */ + headers?: Record; + [key: string]: any; + }; +} + +/** + * An operation that was applied to an object on a channel. + */ +export interface ObjectOperation { + /** The operation action, one of the {@link ObjectOperationAction} enum values. */ + action: ObjectOperationAction; + /** The ID of the object the operation was applied to. */ + objectId: string; + /** The payload for the operation if it is a mutation operation on a map object. */ + mapOp?: ObjectsMapOp; + /** The payload for the operation if it is a mutation operation on a counter object. */ + counterOp?: ObjectsCounterOp; + /** + * The payload for the operation if the action is {@link ObjectOperationActions.MAP_CREATE}. + * Defines the initial value of the map object. + */ + map?: ObjectsMap; + /** + * The payload for the operation if the action is {@link ObjectOperationActions.COUNTER_CREATE}. + * Defines the initial value of the counter object. + */ + counter?: ObjectsCounter; +} + +/** + * Describes an operation that was applied to a map object. + */ +export interface ObjectsMapOp { + /** The key that the operation was applied to. */ + key: string; + /** The data assigned to the key if the operation is {@link ObjectOperationActions.MAP_SET}. */ + data?: ObjectData; +} + +/** + * Describes an operation that was applied to a counter object. + */ +export interface ObjectsCounterOp { + /** The value added to the counter. */ + amount: number; +} + +/** + * Describes the initial value of a map object. + */ +export interface ObjectsMap { + /** The conflict-resolution semantics used by the map object, one of the {@link ObjectsMapSemantics} enum values. */ + semantics?: ObjectsMapSemantics; + /** The map entries, indexed by key. */ + entries?: Record; +} + +/** + * Describes a value at a specific key in a map object. + */ +export interface ObjectsMapEntry { + /** Indicates whether the map entry has been removed. */ + tombstone?: boolean; + /** The {@link ObjectMessage.serial} value of the last operation applied to the map entry. */ + timeserial?: string; + /** A timestamp derived from the {@link timeserial} field. Present only if {@link tombstone} is `true`. */ + serialTimestamp?: number; + /** The value associated with this map entry. */ + data?: ObjectData; +} + +/** + * Describes the initial value of a counter object. + */ +export interface ObjectsCounter { + /** The value of the counter. */ + count?: number; +} + +/** + * Represents a value in an object on a channel. + */ +export interface ObjectData { + /** A reference to another object. */ + objectId?: string; + /** A decoded primitive value. */ + value?: Primitive; +} /** * Static utilities related to LiveMap instances. */ export class LiveMap { /** - * Creates a {@link LiveMapType | LiveMap} value type that can be passed to mutation methods + * Creates a {@link LiveMap} value type that can be passed to mutation methods * (such as {@link LiveMapOperations.set}) to assign a new LiveMap to the channel object. * * @param initialEntries - Optional initial entries for the new LiveMap object. - * @returns A {@link LiveMapType | LiveMap} value type representing the initial state of the new LiveMap. + * @returns A {@link LiveMap} value type representing the initial state of the new LiveMap. * @experimental */ static create>( // block TypeScript from inferring T from the initialEntries argument, so instead it is inferred // from the contextual type in a LiveMap.set call initialEntries?: NoInfer, - ): LiveMapType ? T : {}>; + ): LiveMap ? T : {}>; } /** @@ -48,14 +1841,14 @@ export class LiveMap { */ export class LiveCounter { /** - * Creates a {@link LiveCounterType | LiveCounter} value type that can be passed to mutation methods + * Creates a {@link LiveCounter} value type that can be passed to mutation methods * (such as {@link LiveMapOperations.set}) to assign a new LiveCounter to the channel object. * * @param initialCount - Optional initial count for the new LiveCounter object. - * @returns A {@link LiveCounterType | LiveCounter} value type representing the initial state of the new LiveCounter. + * @returns A {@link LiveCounter} value type representing the initial state of the new LiveCounter. * @experimental */ - static create(initialCount?: number): LiveCounterType; + static create(initialCount?: number): LiveCounter; } /** @@ -84,3 +1877,17 @@ export class LiveCounter { * ``` */ export declare const Objects: any; + +/** + * Module augmentation to add the `object` property to `RealtimeChannel` when + * importing from 'ably/objects'. This ensures all Objects types come from + * the same module (CJS or ESM), avoiding type incompatibility issues. + */ +declare module './ably' { + interface RealtimeChannel { + /** + * A {@link RealtimeObject} object. + */ + object: RealtimeObject; + } +} diff --git a/src/plugins/objects/batchcontext.ts b/src/plugins/objects/batchcontext.ts index 912259ac7e..1f7858acc2 100644 --- a/src/plugins/objects/batchcontext.ts +++ b/src/plugins/objects/batchcontext.ts @@ -7,7 +7,7 @@ import type { Instance, Primitive, Value, -} from '../../../ably'; +} from '../../../objects'; import { DefaultInstance } from './instance'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; diff --git a/src/plugins/objects/instance.ts b/src/plugins/objects/instance.ts index fb98b98672..08e2f9c020 100644 --- a/src/plugins/objects/instance.ts +++ b/src/plugins/objects/instance.ts @@ -1,18 +1,17 @@ import type BaseClient from 'common/lib/client/baseclient'; +import type { EventCallback, Subscription } from '../../../ably'; import type { AnyInstance, BatchContext, BatchFunction, CompactedJsonValue, CompactedValue, - EventCallback, Instance, InstanceSubscriptionEvent, LiveObject as LiveObjectType, Primitive, - Subscription, Value, -} from '../../../ably'; +} from '../../../objects'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; import { LiveObject } from './liveobject'; diff --git a/src/plugins/objects/livecounter.ts b/src/plugins/objects/livecounter.ts index 02d156cc6c..31db134f85 100644 --- a/src/plugins/objects/livecounter.ts +++ b/src/plugins/objects/livecounter.ts @@ -1,3 +1,5 @@ +import { __livetype } from '../../../ably'; +import { LiveCounter as PublicLiveCounter } from '../../../objects'; import { LiveObject, LiveObjectData, LiveObjectUpdate, LiveObjectUpdateNoop } from './liveobject'; import { ObjectData, ObjectMessage, ObjectOperation, ObjectOperationAction, ObjectsCounterOp } from './objectmessage'; import { RealtimeObject } from './realtimeobject'; @@ -12,7 +14,9 @@ export interface LiveCounterUpdate extends LiveObjectUpdate { } /** @spec RTLC1, RTLC2 */ -export class LiveCounter extends LiveObject { +export class LiveCounter extends LiveObject implements PublicLiveCounter { + declare readonly [__livetype]: 'LiveCounter'; // type-only, unique symbol to satisfy branded interfaces, no JS emitted + /** * Returns a {@link LiveCounter} instance with a 0 value. * diff --git a/src/plugins/objects/livecountervaluetype.ts b/src/plugins/objects/livecountervaluetype.ts index 5e43833075..266c17fb84 100644 --- a/src/plugins/objects/livecountervaluetype.ts +++ b/src/plugins/objects/livecountervaluetype.ts @@ -1,4 +1,5 @@ -import type * as API from '../../../ably'; +import { __livetype } from '../../../ably'; +import { LiveCounter } from '../../../objects'; import { ObjectId } from './objectid'; import { createInitialValueJSONString, @@ -17,8 +18,8 @@ import { RealtimeObject } from './realtimeobject'; * Properties of this class are immutable after construction and the instance * will be frozen to prevent mutation. */ -export class LiveCounterValueType implements API.LiveCounter { - declare readonly [API.__livetype]: 'LiveCounter'; // type-only, unique symbol to satisfy branded interfaces, no JS emitted +export class LiveCounterValueType implements LiveCounter { + declare readonly [__livetype]: 'LiveCounter'; // type-only, unique symbol to satisfy branded interfaces, no JS emitted private readonly _livetype = 'LiveCounter'; // use a runtime property to provide a reliable cross-bundle type identification instead of `instanceof` operator private readonly _count: number; @@ -27,7 +28,7 @@ export class LiveCounterValueType implements API.LiveCounter { Object.freeze(this); } - static create(initialCount: number = 0): API.LiveCounter { + static create(initialCount: number = 0): LiveCounter { // We can't directly import the ErrorInfo class from the core library into the plugin (as this would bloat the plugin size), // and, since we're in a user-facing static method, we can't expect a user to pass a client library instance, as this would make the API ugly. // Since we can't use ErrorInfo here, we won't do any validation at this step; instead, validation will happen in the mutation methods diff --git a/src/plugins/objects/livemap.ts b/src/plugins/objects/livemap.ts index 0a075c7b16..2255c53905 100644 --- a/src/plugins/objects/livemap.ts +++ b/src/plugins/objects/livemap.ts @@ -1,6 +1,14 @@ import { dequal } from 'dequal'; +import { __livetype } from '../../../ably'; -import type * as API from '../../../ably'; +import { + CompactedJsonValue, + CompactedValue, + LiveMap as PublicLiveMap, + LiveObject as PublicLiveObject, + Primitive, + Value, +} from '../../../objects'; import { LiveCounter } from './livecounter'; import { LiveCounterValueType } from './livecountervaluetype'; import { LiveMapValueType } from './livemapvaluetype'; @@ -23,7 +31,7 @@ export interface ObjectIdObjectData { export interface ValueObjectData { /** A decoded leaf value from {@link WireObjectData}. */ - value: API.Primitive; + value: Primitive; } export type LiveMapObjectData = ObjectIdObjectData | ValueObjectData; @@ -39,17 +47,17 @@ export interface LiveMapData extends LiveObjectData { data: Map; // RTLM3 } -export interface LiveMapUpdate> extends LiveObjectUpdate { +export interface LiveMapUpdate> extends LiveObjectUpdate { update: { [keyName in keyof T & string]?: 'updated' | 'removed' }; _type: 'LiveMapUpdate'; } /** @spec RTLM1, RTLM2 */ -export class LiveMap = Record> +export class LiveMap = Record> extends LiveObject> - implements API.LiveMap + implements PublicLiveMap { - declare readonly [API.__livetype]: 'LiveMap'; // type-only, unique symbol to satisfy branded interfaces, no JS emitted + declare readonly [__livetype]: 'LiveMap'; // type-only, unique symbol to satisfy branded interfaces, no JS emitted constructor( realtimeObject: RealtimeObject, @@ -88,7 +96,7 @@ export class LiveMap = Record { const client = realtimeObject.getClient(); @@ -113,7 +121,7 @@ export class LiveMap = Record = Record = Record>): API.CompactedValue> { + compact(visitedObjects?: Map>): CompactedValue> { const visited = visitedObjects ?? new Map>(); const result: Record = {} as Record; @@ -540,7 +548,7 @@ export class LiveMap = Record): API.CompactedJsonValue> { + compactJson(visitedObjectIds?: Set): CompactedJsonValue> { const visited = visitedObjectIds ?? new Set(); const result: Record = {} as Record; @@ -954,7 +962,7 @@ export class LiveMap = Record = Record = Record> - implements API.LiveMap -{ - declare readonly [API.__livetype]: 'LiveMap'; // type-only, unique symbol to satisfy branded interfaces, no JS emitted +export class LiveMapValueType = Record> implements PublicLiveMap { + declare readonly [__livetype]: 'LiveMap'; // type-only, unique symbol to satisfy branded interfaces, no JS emitted private readonly _livetype = 'LiveMap'; // use a runtime property to provide a reliable cross-bundle type identification instead of `instanceof` operator private readonly _entries: T | undefined; @@ -42,9 +41,9 @@ export class LiveMapValueType = Record>( + static create>( initialEntries?: T, - ): API.LiveMap ? T : {}> { + ): PublicLiveMap ? T : {}> { // We can't directly import the ErrorInfo class from the core library into the plugin (as this would bloat the plugin size), // and, since we're in a user-facing static method, we can't expect a user to pass a client library instance, as this would make the API ugly. // Since we can't use ErrorInfo here, we won't do any validation at this step; instead, validation will happen in the mutation methods @@ -114,7 +113,7 @@ export class LiveMapValueType = Record, + entries?: Record, ): Promise<{ initialValueOperation: Pick, 'map'>; nestedObjectsCreateMsgs: ObjectMessage[]; @@ -138,7 +137,7 @@ export class LiveMapValueType = Record WireObjectData; @@ -41,7 +40,7 @@ export interface ObjectData { /** A reference to another object, used to support composable object structures. */ objectId?: string; // OD2a /** A decoded leaf value from {@link WireObjectData}. */ - value?: API.Primitive; + value?: ObjectsApi.Primitive; } /** @@ -337,7 +336,7 @@ function copyMsg( return result; } -function stringifyOperation(operation: ObjectOperation): API.ObjectOperation { +function stringifyOperation(operation: ObjectOperation): ObjectsApi.ObjectOperation { return { ...operation, action: operationActions[operation.action] || 'unknown', @@ -444,7 +443,7 @@ export class ObjectMessage { return this.object != null; } - toUserFacingMessage(channel: RealtimeChannel): API.ObjectMessage { + toUserFacingMessage(channel: RealtimeChannel): ObjectsApi.ObjectMessage { return { id: this.id!, clientId: this.clientId, @@ -789,7 +788,7 @@ export class WireObjectMessage { client.Platform.BufferUtils.base64Decode(String(objectData.bytes)); } - let decodedJson: JsonObject | JsonArray | undefined; + let decodedJson: ObjectsApi.JsonObject | ObjectsApi.JsonArray | undefined; if (objectData.json != null) { decodedJson = JSON.parse(objectData.json); // OD5a2, OD5b3 } diff --git a/src/plugins/objects/pathobject.ts b/src/plugins/objects/pathobject.ts index d988f82e94..9e1e9c2438 100644 --- a/src/plugins/objects/pathobject.ts +++ b/src/plugins/objects/pathobject.ts @@ -1,20 +1,19 @@ import type BaseClient from 'common/lib/client/baseclient'; +import type { EventCallback, Subscription } from '../../../ably'; import type { AnyPathObject, BatchContext, BatchFunction, CompactedJsonValue, CompactedValue, - EventCallback, Instance, LiveObject as LiveObjectType, PathObject, PathObjectSubscriptionEvent, PathObjectSubscriptionOptions, Primitive, - Subscription, Value, -} from '../../../ably'; +} from '../../../objects'; import { DefaultInstance } from './instance'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; diff --git a/src/plugins/objects/pathobjectsubscriptionregister.ts b/src/plugins/objects/pathobjectsubscriptionregister.ts index 00e913b4d7..2649aaaafb 100644 --- a/src/plugins/objects/pathobjectsubscriptionregister.ts +++ b/src/plugins/objects/pathobjectsubscriptionregister.ts @@ -1,10 +1,6 @@ import type BaseClient from 'common/lib/client/baseclient'; -import type { - EventCallback, - PathObjectSubscriptionEvent, - PathObjectSubscriptionOptions, - Subscription, -} from '../../../ably'; +import type { EventCallback, Subscription } from '../../../ably'; +import type { PathObjectSubscriptionEvent, PathObjectSubscriptionOptions } from '../../../objects'; import { ObjectMessage } from './objectmessage'; import { DefaultPathObject } from './pathobject'; import { RealtimeObject } from './realtimeobject'; diff --git a/src/plugins/objects/realtimeobject.ts b/src/plugins/objects/realtimeobject.ts index 75f53b99f5..bfa348b218 100644 --- a/src/plugins/objects/realtimeobject.ts +++ b/src/plugins/objects/realtimeobject.ts @@ -1,7 +1,8 @@ import type BaseClient from 'common/lib/client/baseclient'; import type RealtimeChannel from 'common/lib/client/realtimechannel'; import type EventEmitter from 'common/lib/util/eventemitter'; -import type * as API from '../../../ably'; +import type { ChannelState } from '../../../ably'; +import type * as ObjectsApi from '../../../objects'; import { DEFAULTS } from './defaults'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; @@ -79,7 +80,7 @@ export class RealtimeObject { * A user can provide an explicit type for the this method to explicitly set the type structure on this particular channel. * This is useful when working with multiple channels with different underlying data structure. */ - async get>(): Promise>> { + async get>(): Promise>> { this._throwIfMissingChannelMode('object_subscribe'); // implicit attach before proceeding @@ -214,7 +215,7 @@ export class RealtimeObject { /** * @internal */ - actOnChannelState(state: API.ChannelState, hasObjects?: boolean): void { + actOnChannelState(state: ChannelState, hasObjects?: boolean): void { switch (state) { case 'attached': this.onAttached(hasObjects); @@ -458,7 +459,7 @@ export class RealtimeObject { } } - private _throwIfInChannelState(channelState: API.ChannelState[]): void { + private _throwIfInChannelState(channelState: ChannelState[]): void { if (channelState.includes(this._channel.state)) { throw this._client.ErrorInfo.fromValues(this._channel.invalidStateError()); } diff --git a/src/plugins/objects/rootbatchcontext.ts b/src/plugins/objects/rootbatchcontext.ts index 9978be1def..4edbbee01a 100644 --- a/src/plugins/objects/rootbatchcontext.ts +++ b/src/plugins/objects/rootbatchcontext.ts @@ -1,4 +1,4 @@ -import type { Instance, Value } from '../../../ably'; +import type { Instance, Value } from '../../../objects'; import { DefaultBatchContext } from './batchcontext'; import { ObjectMessage } from './objectmessage'; import { RealtimeObject } from './realtimeobject'; diff --git a/test/package/browser/template/src/index-objects.ts b/test/package/browser/template/src/index-objects.ts index d1a9f294c2..f56112e3d5 100644 --- a/test/package/browser/template/src/index-objects.ts +++ b/test/package/browser/template/src/index-objects.ts @@ -1,6 +1,16 @@ -import * as Ably from 'ably'; -import { CompactedJsonValue, CompactedValue, LiveCounter, LiveMap } from 'ably'; -import { Objects } from 'ably/objects'; +import { Realtime } from 'ably'; +import { + AnyPathObject, + CompactedJsonValue, + CompactedValue, + LiveCounter, + LiveCounterPathObject, + LiveMap, + LiveMapPathObject, + ObjectMessage, + Objects, + PathObject, +} from 'ably/objects'; import { createSandboxAblyAPIKey } from './sandbox'; // Fix for "type 'typeof globalThis' has no index signature" error: @@ -28,13 +38,13 @@ type MyCustomObject = { globalThis.testAblyPackage = async function () { const key = await createSandboxAblyAPIKey(); - const realtime = new Ably.Realtime({ key, endpoint: 'nonprod:sandbox', plugins: { Objects } }); + const realtime = new Realtime({ key, endpoint: 'nonprod:sandbox', plugins: { Objects } }); const channel = realtime.channels.get('channel', { modes: ['OBJECT_SUBSCRIBE', 'OBJECT_PUBLISH'] }); await channel.attach(); // check Objects can be accessed on a channel with a custom type parameter. - // check that we can refer to the Objects types exported from 'ably' by referencing a LiveMap interface. - const myObject: Ably.PathObject> = await channel.object.get(); + // check that we can refer to the Objects types exported from 'ably/objects' by referencing a LiveMap interface. + const myObject: PathObject> = await channel.object.get(); // check entrypoint has expected LiveMap TypeScript type methods const size: number | undefined = myObject.size(); @@ -46,9 +56,8 @@ globalThis.testAblyPackage = async function () { const aBoolean: boolean | undefined = myObject.get('booleanKey').value(); const userProvidedUndefined: string | undefined = myObject.get('couldBeUndefined').value(); // objects: - const counter: Ably.LiveCounterPathObject = myObject.get('counterKey'); - const map: Ably.LiveMapPathObject ? T : never> = - myObject.get('mapKey'); + const counter: LiveCounterPathObject = myObject.get('counterKey'); + const map: LiveMapPathObject ? T : never> = myObject.get('mapKey'); // check string literal types works // need to use nullish coalescing as we didn't actually create any data on the entrypoint object, // so the next calls would fail. we only need to check that TypeScript types work @@ -59,8 +68,8 @@ globalThis.testAblyPackage = async function () { // check subscription callback has correct TypeScript types const { unsubscribe } = myObject.subscribe(({ object, message }) => { - const typedObject: Ably.AnyPathObject = object; - const typedMessage: Ably.ObjectMessage | undefined = message; + const typedObject: AnyPathObject = object; + const typedMessage: ObjectMessage | undefined = message; }); unsubscribe(); From 1e79b400d0de99913863815e4159d632c46e624f Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Thu, 18 Dec 2025 16:24:39 +0000 Subject: [PATCH 32/45] Fix typedoc build failing after Objects types move from ably.d.ts to objects.d.ts Problem: After moving Objects types from ably.d.ts to objects.d.ts, `npm run docs` failed with: "RealtimeObject, defined in ./objects.d.ts, is referenced by ably.RealtimeChannel.object but not included in the documentation." Adding objects.d.ts as a typedoc entry point initially seemed to fix it, but after running `npm run build`, docs failed again with: "object has multiple declarations with a comment" pointing to both objects.d.ts:1888 and objects.d.mts:1888. Attempted fixes that didn't work: - typedoc's `exclude: ["objects.d.mts"]` option - typedoc's `exclude: ["**/objects.d.mts"]` glob pattern Both still resulted in typedoc finding the .mts file and failing. Root cause: Both objects.d.ts and objects.d.mts contain identical module augmentation (`declare module './ably'`) adding the `object` property to RealtimeChannel. TypeDoc's `exclude` option only prevents files from being used as entry points (or at least it seems so to me) - it doesn't prevent TypeScript from resolving them as dependencies during module resolution. As a result, TypeDoc manages to discover objects.d.mts through its module resolution algorithm. Solution: Create a dedicated tsconfig.typedoc.json that extends the base tsconfig but explicitly excludes objects.d.mts. This prevents TypeScript itself from seeing the file during typedoc's analysis, avoiding the duplicate declaration conflict. --- tsconfig.typedoc.json | 4 ++++ typedoc.json | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 tsconfig.typedoc.json diff --git a/tsconfig.typedoc.json b/tsconfig.typedoc.json new file mode 100644 index 0000000000..4f2342baa5 --- /dev/null +++ b/tsconfig.typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/platform/react-hooks", "test/package", "objects.d.mts"] +} diff --git a/typedoc.json b/typedoc.json index 24faf3bad9..027b9fcd28 100644 --- a/typedoc.json +++ b/typedoc.json @@ -1,6 +1,7 @@ { "$schema": "https://typedoc.org/schema.json", - "entryPoints": ["ably.d.ts", "modular.d.ts"], + "entryPoints": ["ably.d.ts", "modular.d.ts", "objects.d.ts"], + "tsconfig": "tsconfig.typedoc.json", "out": "typedoc/generated", "readme": "typedoc/landing-page.md", "treatWarningsAsErrors": true, From 5576f85c4deb40cc56001728740033d2ab18f85d Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Thu, 18 Dec 2025 15:13:05 +0000 Subject: [PATCH 33/45] Change library/import naming from Objects to LiveObjects Changes import from 'ably/objects' to 'ably/liveobjects' and renames the named export from 'Objects' to 'LiveObjects'. See SDK and Plugin Naming Conventions for Multi-Product Architecture DR: https://ably.atlassian.net/wiki/x/GoAJ-g --- .gitignore | 2 +- Gruntfile.js | 26 ++-- ably.d.ts | 4 +- grunt/esbuild/build.js | 38 ++--- objects.d.ts => liveobjects.d.ts | 24 +-- package.json | 20 +-- scripts/moduleReport.ts | 50 +++--- src/common/lib/client/baserealtime.ts | 6 +- src/common/lib/client/modularplugins.ts | 4 +- src/common/lib/client/realtimechannel.ts | 8 +- src/common/lib/transport/comettransport.ts | 2 +- src/common/lib/transport/connectionmanager.ts | 2 +- src/common/lib/transport/protocol.ts | 2 +- src/common/lib/transport/transport.ts | 2 +- .../lib/transport/websockettransport.ts | 2 +- src/common/lib/types/protocolmessage.ts | 24 +-- src/plugins/index.d.ts | 4 +- .../{objects => liveobjects}/batchcontext.ts | 2 +- .../{objects => liveobjects}/constants.ts | 0 .../{objects => liveobjects}/defaults.ts | 0 src/plugins/{objects => liveobjects}/index.ts | 4 +- .../{objects => liveobjects}/instance.ts | 2 +- .../{objects => liveobjects}/livecounter.ts | 2 +- .../livecountervaluetype.ts | 2 +- .../{objects => liveobjects}/livemap.ts | 6 +- .../livemapvaluetype.ts | 2 +- .../{objects => liveobjects}/liveobject.ts | 0 .../{objects => liveobjects}/objectid.ts | 0 .../{objects => liveobjects}/objectmessage.ts | 2 +- .../{objects => liveobjects}/objectspool.ts | 2 +- .../{objects => liveobjects}/pathobject.ts | 2 +- .../pathobjectsubscriptionregister.ts | 2 +- .../realtimeobject.ts | 2 +- .../rootbatchcontext.ts | 2 +- .../syncobjectsdatapool.ts | 0 test/common/globals/named_dependencies.js | 12 +- ...bjects_helper.js => liveobjects_helper.js} | 8 +- test/package/browser/template/README.md | 4 +- test/package/browser/template/package.json | 2 +- ...ex-objects.html => index-liveobjects.html} | 4 +- .../package/browser/template/server/server.ts | 2 +- ...{index-objects.ts => index-liveobjects.ts} | 10 +- .../browser/template/test/lib/package.test.ts | 2 +- .../{objects.test.js => liveobjects.test.js} | 146 +++++++++--------- test/support/browser_file_list.js | 2 +- tsconfig.typedoc.json | 2 +- typedoc.json | 2 +- 47 files changed, 227 insertions(+), 221 deletions(-) rename objects.d.ts => liveobjects.d.ts (98%) rename src/plugins/{objects => liveobjects}/batchcontext.ts (99%) rename src/plugins/{objects => liveobjects}/constants.ts (100%) rename src/plugins/{objects => liveobjects}/defaults.ts (100%) rename src/plugins/{objects => liveobjects}/index.ts (82%) rename src/plugins/{objects => liveobjects}/instance.ts (99%) rename src/plugins/{objects => liveobjects}/livecounter.ts (99%) rename src/plugins/{objects => liveobjects}/livecountervaluetype.ts (98%) rename src/plugins/{objects => liveobjects}/livemap.ts (99%) rename src/plugins/{objects => liveobjects}/livemapvaluetype.ts (98%) rename src/plugins/{objects => liveobjects}/liveobject.ts (100%) rename src/plugins/{objects => liveobjects}/objectid.ts (100%) rename src/plugins/{objects => liveobjects}/objectmessage.ts (99%) rename src/plugins/{objects => liveobjects}/objectspool.ts (97%) rename src/plugins/{objects => liveobjects}/pathobject.ts (99%) rename src/plugins/{objects => liveobjects}/pathobjectsubscriptionregister.ts (99%) rename src/plugins/{objects => liveobjects}/realtimeobject.ts (99%) rename src/plugins/{objects => liveobjects}/rootbatchcontext.ts (97%) rename src/plugins/{objects => liveobjects}/syncobjectsdatapool.ts (100%) rename test/common/modules/{objects_helper.js => liveobjects_helper.js} (97%) rename test/package/browser/template/server/resources/{index-objects.html => index-liveobjects.html} (54%) rename test/package/browser/template/src/{index-objects.ts => index-liveobjects.ts} (93%) rename test/realtime/{objects.test.js => liveobjects.test.js} (98%) diff --git a/.gitignore b/.gitignore index c43e7d430d..264fe143c1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ ably-js.iml node_modules npm-debug.log .tool-versions -objects.d.mts +liveobjects.d.mts build/ react/ typedoc/generated/ diff --git a/Gruntfile.js b/Gruntfile.js index c10d197feb..57f52ef7d9 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -73,7 +73,7 @@ module.exports = function (grunt) { }); }); - grunt.registerTask('build', ['webpack:all', 'build:browser', 'build:node', 'build:push', 'build:objects']); + grunt.registerTask('build', ['webpack:all', 'build:browser', 'build:node', 'build:push', 'build:liveobjects']); grunt.registerTask('all', ['build', 'requirejs']); @@ -138,14 +138,14 @@ module.exports = function (grunt) { }); }); - grunt.registerTask('build:objects:bundle', function () { + grunt.registerTask('build:liveobjects:bundle', function () { var done = this.async(); Promise.all([ - esbuild.build(esbuildConfig.objectsPluginConfig), - esbuild.build(esbuildConfig.objectsPluginEsmConfig), - esbuild.build(esbuildConfig.objectsPluginCdnConfig), - esbuild.build(esbuildConfig.minifiedObjectsPluginCdnConfig), + esbuild.build(esbuildConfig.liveObjectsPluginConfig), + esbuild.build(esbuildConfig.liveObjectsPluginEsmConfig), + esbuild.build(esbuildConfig.liveObjectsPluginCdnConfig), + esbuild.build(esbuildConfig.minifiedLiveObjectsPluginCdnConfig), ]) .then(() => { done(true); @@ -156,22 +156,22 @@ module.exports = function (grunt) { }); grunt.registerTask( - 'build:objects:types', - 'Generate objects.d.mts from objects.d.ts by adding .js extensions to relative imports', + 'build:liveobjects:types', + 'Generate liveobjects.d.mts from liveobjects.d.ts by adding .js extensions to relative imports', function () { - const dtsContent = fs.readFileSync('objects.d.ts', 'utf8'); + const dtsContent = fs.readFileSync('liveobjects.d.ts', 'utf8'); const mtsContent = dtsContent.replace(/from '(\.\/[^']+)'/g, "from '$1.js'"); - fs.writeFileSync('objects.d.mts', mtsContent); - grunt.log.ok('Generated objects.d.mts from objects.d.ts'); + fs.writeFileSync('liveobjects.d.mts', mtsContent); + grunt.log.ok('Generated liveobjects.d.mts from liveobjects.d.ts'); }, ); - grunt.registerTask('build:objects', ['build:objects:bundle', 'build:objects:types']); + grunt.registerTask('build:liveobjects', ['build:liveobjects:bundle', 'build:liveobjects:types']); grunt.registerTask('test:webserver', 'Launch the Mocha test web server on http://localhost:3000/', [ 'build:browser', 'build:push', - 'build:objects', + 'build:liveobjects', 'checkGitSubmodules', 'mocha:webserver', ]); diff --git a/ably.d.ts b/ably.d.ts index b94e279b95..5d3553fc26 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -304,7 +304,7 @@ export type Transport = 'web_socket' | 'xhr_polling' | 'comet'; * This enables TypeScript to distinguish between these otherwise empty interfaces, * which would be structurally identical without this discriminating property. * - * This symbol is exported from 'ably' so that the types in 'ably/objects' + * This symbol is exported from 'ably' so that the types in 'ably/liveobjects' * (both ESM and CJS versions) share the same symbol, ensuring type compatibility. */ export declare const __livetype: unique symbol; @@ -638,7 +638,7 @@ export interface CorePlugins { /** * A plugin which allows the client to use LiveObjects functionality at {@link RealtimeChannel.object}. */ - Objects?: unknown; + LiveObjects?: unknown; } /** diff --git a/grunt/esbuild/build.js b/grunt/esbuild/build.js index 5aea84f3ba..b6d7b95c04 100644 --- a/grunt/esbuild/build.js +++ b/grunt/esbuild/build.js @@ -77,35 +77,35 @@ const minifiedPushPluginCdnConfig = { minify: true, }; -const objectsPluginConfig = { +const liveObjectsPluginConfig = { ...createBaseConfig(), - entryPoints: ['src/plugins/objects/index.ts'], - plugins: [umdWrapper.default({ libraryName: 'AblyObjectsPlugin', amdNamedModule: false })], - outfile: 'build/objects.js', + entryPoints: ['src/plugins/liveobjects/index.ts'], + plugins: [umdWrapper.default({ libraryName: 'AblyLiveObjectsPlugin', amdNamedModule: false })], + outfile: 'build/liveobjects.js', external: ['dequal'], }; -const objectsPluginEsmConfig = { +const liveObjectsPluginEsmConfig = { ...createBaseConfig(), format: 'esm', plugins: [], - entryPoints: ['src/plugins/objects/index.ts'], - outfile: 'build/objects.mjs', + entryPoints: ['src/plugins/liveobjects/index.ts'], + outfile: 'build/liveobjects.mjs', external: ['dequal'], }; -const objectsPluginCdnConfig = { +const liveObjectsPluginCdnConfig = { ...createBaseConfig(), - entryPoints: ['src/plugins/objects/index.ts'], - plugins: [umdWrapper.default({ libraryName: 'AblyObjectsPlugin', amdNamedModule: false })], - outfile: 'build/objects.umd.js', + entryPoints: ['src/plugins/liveobjects/index.ts'], + plugins: [umdWrapper.default({ libraryName: 'AblyLiveObjectsPlugin', amdNamedModule: false })], + outfile: 'build/liveobjects.umd.js', }; -const minifiedObjectsPluginCdnConfig = { +const minifiedLiveObjectsPluginCdnConfig = { ...createBaseConfig(), - entryPoints: ['src/plugins/objects/index.ts'], - plugins: [umdWrapper.default({ libraryName: 'AblyObjectsPlugin', amdNamedModule: false })], - outfile: 'build/objects.umd.min.js', + entryPoints: ['src/plugins/liveobjects/index.ts'], + plugins: [umdWrapper.default({ libraryName: 'AblyLiveObjectsPlugin', amdNamedModule: false })], + outfile: 'build/liveobjects.umd.min.js', minify: true, }; @@ -117,8 +117,8 @@ module.exports = { pushPluginConfig, pushPluginCdnConfig, minifiedPushPluginCdnConfig, - objectsPluginConfig, - objectsPluginEsmConfig, - objectsPluginCdnConfig, - minifiedObjectsPluginCdnConfig, + liveObjectsPluginConfig, + liveObjectsPluginEsmConfig, + liveObjectsPluginCdnConfig, + minifiedLiveObjectsPluginCdnConfig, }; diff --git a/objects.d.ts b/liveobjects.d.ts similarity index 98% rename from objects.d.ts rename to liveobjects.d.ts index 9de8322041..700575b1df 100644 --- a/objects.d.ts +++ b/liveobjects.d.ts @@ -1,7 +1,7 @@ /** - * You are currently viewing the Ably Objects plugin type definitions for the Ably JavaScript Client Library SDK. + * You are currently viewing the Ably LiveObjects plugin type definitions for the Ably JavaScript Client Library SDK. * - * To get started with Objects, follow the [Quickstart Guide](https://ably.com/docs/liveobjects/quickstart/javascript) or view the [Introduction to Objects](https://ably.com/docs/liveobjects). + * To get started with LiveObjects, follow the [Quickstart Guide](https://ably.com/docs/liveobjects/quickstart/javascript) or view the [Introduction to LiveObjects](https://ably.com/docs/liveobjects). * * @module */ @@ -70,7 +70,7 @@ export declare interface RealtimeObject { * Example: * * ```typescript - * import { LiveCounter } from 'ably/objects'; + * import { LiveCounter } from 'ably/liveobjects'; * * type MyObject = { * myTypedCounter: LiveCounter; @@ -1852,35 +1852,35 @@ export class LiveCounter { } /** - * The Objects plugin that provides a {@link RealtimeClient} instance with the ability to use Objects functionality. + * The LiveObjects plugin that provides a {@link RealtimeClient} instance with the ability to use LiveObjects functionality. * * To create a client that includes this plugin, include it in the client options that you pass to the {@link RealtimeClient.constructor}: * * ```javascript * import { Realtime } from 'ably'; - * import { Objects } from 'ably/objects'; - * const realtime = new Realtime({ ...options, plugins: { Objects } }); + * import { LiveObjects } from 'ably/liveobjects'; + * const realtime = new Realtime({ ...options, plugins: { LiveObjects } }); * ``` * - * The Objects plugin can also be used with a {@link BaseRealtime} client: + * The LiveObjects plugin can also be used with a {@link BaseRealtime} client: * * ```javascript * import { BaseRealtime, WebSocketTransport, FetchRequest } from 'ably/modular'; - * import { Objects } from 'ably/objects'; - * const realtime = new BaseRealtime({ ...options, plugins: { WebSocketTransport, FetchRequest, Objects } }); + * import { LiveObjects } from 'ably/liveobjects'; + * const realtime = new BaseRealtime({ ...options, plugins: { WebSocketTransport, FetchRequest, LiveObjects } }); * ``` * * You can also import individual utilities alongside the plugin: * * ```javascript - * import { Objects, LiveCounter, LiveMap } from 'ably/objects'; + * import { LiveObjects, LiveCounter, LiveMap } from 'ably/liveobjects'; * ``` */ -export declare const Objects: any; +export declare const LiveObjects: any; /** * Module augmentation to add the `object` property to `RealtimeChannel` when - * importing from 'ably/objects'. This ensures all Objects types come from + * importing from 'ably/liveobjects'. This ensures all LiveObjects types come from * the same module (CJS or ESM), avoiding type incompatibility issues. */ declare module './ably' { diff --git a/package.json b/package.json index 8edabca7f5..513df5bfa3 100644 --- a/package.json +++ b/package.json @@ -30,22 +30,22 @@ "types": "./push.d.ts", "import": "./build/push.js" }, - "./objects": { + "./liveobjects": { "import": { - "types": "./objects.d.mts", - "default": "./build/objects.mjs" + "types": "./liveobjects.d.mts", + "default": "./build/liveobjects.mjs" }, "require": { - "types": "./objects.d.ts", - "default": "./build/objects.js" + "types": "./liveobjects.d.ts", + "default": "./build/liveobjects.js" } } }, "files": [ "build/**", "ably.d.ts", - "objects.d.ts", - "objects.d.mts", + "liveobjects.d.ts", + "liveobjects.d.mts", "modular.d.ts", "push.d.ts", "resources/**", @@ -151,8 +151,8 @@ "start:react": "npx vite serve", "grunt": "grunt", "test": "npm run test:node", - "test:node": "npm run build:node && npm run build:push && npm run build:objects && mocha", - "test:grep": "npm run build:node && npm run build:push && npm run build:objects && mocha --grep", + "test:node": "npm run build:node && npm run build:push && npm run build:liveobjects && mocha", + "test:grep": "npm run build:node && npm run build:push && npm run build:liveobjects && mocha --grep", "test:node:skip-build": "mocha", "test:webserver": "grunt test:webserver", "test:playwright": "node test/support/runPlaywrightTests.js", @@ -166,7 +166,7 @@ "build:react:mjs": "tsc --project src/platform/react-hooks/tsconfig.mjs.json && cp src/platform/react-hooks/res/package.mjs.json react/mjs/package.json", "build:react:cjs": "tsc --project src/platform/react-hooks/tsconfig.cjs.json && cp src/platform/react-hooks/res/package.cjs.json react/cjs/package.json", "build:push": "grunt build:push", - "build:objects": "grunt build:objects", + "build:liveobjects": "grunt build:liveobjects", "requirejs": "grunt requirejs", "lint": "eslint .", "lint:fix": "eslint --fix .", diff --git a/scripts/moduleReport.ts b/scripts/moduleReport.ts index af5444d6ae..3dae6fc282 100644 --- a/scripts/moduleReport.ts +++ b/scripts/moduleReport.ts @@ -46,9 +46,9 @@ interface PluginInfo { external?: string[]; } -const buildablePlugins: Record<'push' | 'objects', PluginInfo> = { +const buildablePlugins: Record<'push' | 'liveobjects', PluginInfo> = { push: { description: 'Push', path: './build/push.js', external: ['ulid'] }, - objects: { description: 'Objects', path: './build/objects.js', external: ['dequal'] }, + liveobjects: { description: 'LiveObjects', path: './build/liveobjects.js', external: ['dequal'] }, }; function formatBytes(bytes: number) { @@ -217,8 +217,8 @@ async function calculatePushPluginSize(): Promise { return calculatePluginSize(buildablePlugins.push); } -async function calculateObjectsPluginSize(): Promise { - return calculatePluginSize(buildablePlugins.objects); +async function calculateLiveObjectsPluginSize(): Promise { + return calculatePluginSize(buildablePlugins.liveobjects); } async function calculateAndCheckMinimalUsefulRealtimeBundleSize(): Promise { @@ -321,28 +321,28 @@ async function checkPushPluginFiles() { return checkBundleFiles(pushPluginBundleInfo, allowedFiles, 100); } -async function checkObjectsPluginFiles() { - const { path, external } = buildablePlugins.objects; +async function checkLiveObjectsPluginFiles() { + const { path, external } = buildablePlugins.liveobjects; const pluginBundleInfo = getBundleInfo(path, undefined, external); - // These are the files that are allowed to contribute >= `threshold` bytes to the Objects bundle. + // These are the files that are allowed to contribute >= `threshold` bytes to the LiveObjects bundle. const allowedFiles = new Set([ - 'src/plugins/objects/batchcontext.ts', - 'src/plugins/objects/index.ts', - 'src/plugins/objects/instance.ts', - 'src/plugins/objects/livecounter.ts', - 'src/plugins/objects/livecountervaluetype.ts', - 'src/plugins/objects/livemap.ts', - 'src/plugins/objects/livemapvaluetype.ts', - 'src/plugins/objects/liveobject.ts', - 'src/plugins/objects/objectid.ts', - 'src/plugins/objects/objectmessage.ts', - 'src/plugins/objects/objectspool.ts', - 'src/plugins/objects/pathobject.ts', - 'src/plugins/objects/pathobjectsubscriptionregister.ts', - 'src/plugins/objects/realtimeobject.ts', - 'src/plugins/objects/rootbatchcontext.ts', - 'src/plugins/objects/syncobjectsdatapool.ts', + 'src/plugins/liveobjects/batchcontext.ts', + 'src/plugins/liveobjects/index.ts', + 'src/plugins/liveobjects/instance.ts', + 'src/plugins/liveobjects/livecounter.ts', + 'src/plugins/liveobjects/livecountervaluetype.ts', + 'src/plugins/liveobjects/livemap.ts', + 'src/plugins/liveobjects/livemapvaluetype.ts', + 'src/plugins/liveobjects/liveobject.ts', + 'src/plugins/liveobjects/objectid.ts', + 'src/plugins/liveobjects/objectmessage.ts', + 'src/plugins/liveobjects/objectspool.ts', + 'src/plugins/liveobjects/pathobject.ts', + 'src/plugins/liveobjects/pathobjectsubscriptionregister.ts', + 'src/plugins/liveobjects/realtimeobject.ts', + 'src/plugins/liveobjects/rootbatchcontext.ts', + 'src/plugins/liveobjects/syncobjectsdatapool.ts', ]); return checkBundleFiles(pluginBundleInfo, allowedFiles, 100); @@ -399,7 +399,7 @@ async function checkBundleFiles(bundleInfo: BundleInfo, allowedFiles: Set ({ tableRows: [...accum.tableRows, ...current.tableRows], @@ -408,7 +408,7 @@ async function checkBundleFiles(bundleInfo: BundleInfo, allowedFiles: Set>(serialized, MsgPack, format); @@ -42,7 +42,7 @@ export function fromDeserialized( deserialized: Record, presenceMessagePlugin: PresenceMessagePlugin | null, annotationsPlugin: AnnotationsPlugin | null, - objectsPlugin: typeof ObjectsPlugin | null, + objectsPlugin: typeof LiveObjectsPlugin | null, ): ProtocolMessage { let error: ErrorInfo | undefined; if (deserialized.error) { @@ -68,10 +68,10 @@ export function fromDeserialized( ); } - let state: ObjectsPlugin.WireObjectMessage[] | undefined; + let state: LiveObjectsPlugin.WireObjectMessage[] | undefined; if (objectsPlugin && deserialized.state) { state = objectsPlugin.WireObjectMessage.fromValuesArray( - deserialized.state as ObjectsPlugin.WireObjectMessage[], + deserialized.state as LiveObjectsPlugin.WireObjectMessage[], Utils, MessageEncoding, ); @@ -83,10 +83,12 @@ export function fromDeserialized( /** * Used internally by the tests. * - * ObjectsPlugin code can't be included as part of the core library to prevent size growth, + * LiveObjectsPlugin code can't be included as part of the core library to prevent size growth, * so if a test needs to build object messages, then it must provide the plugin upon call. */ -export function makeFromDeserializedWithDependencies(dependencies?: { ObjectsPlugin: typeof ObjectsPlugin | null }) { +export function makeFromDeserializedWithDependencies(dependencies?: { + LiveObjectsPlugin: typeof LiveObjectsPlugin | null; +}) { return (deserialized: Record): ProtocolMessage => { return fromDeserialized( deserialized, @@ -95,7 +97,7 @@ export function makeFromDeserializedWithDependencies(dependencies?: { ObjectsPlu WirePresenceMessage, }, { Annotation, WireAnnotation, RealtimeAnnotations, RestAnnotations }, - dependencies?.ObjectsPlugin ?? null, + dependencies?.LiveObjectsPlugin ?? null, ); }; } @@ -108,7 +110,7 @@ export function stringify( msg: any, presenceMessagePlugin: PresenceMessagePlugin | null, annotationsPlugin: AnnotationsPlugin | null, - objectsPlugin: typeof ObjectsPlugin | null, + objectsPlugin: typeof LiveObjectsPlugin | null, ): string { let result = '[ProtocolMessage'; if (msg.action !== undefined) result += '; action=' + ActionName[msg.action] || msg.action; @@ -167,9 +169,9 @@ class ProtocolMessage { presence?: WirePresenceMessage[]; annotations?: WireAnnotation[]; /** - * This will be undefined if we skipped decoding this property due to user not requesting Objects functionality — see {@link fromDeserialized} + * This will be undefined if we skipped decoding this property due to user not requesting LiveObjects functionality — see {@link fromDeserialized} */ - state?: ObjectsPlugin.WireObjectMessage[]; // TR4r + state?: LiveObjectsPlugin.WireObjectMessage[]; // TR4r auth?: unknown; connectionDetails?: Record; params?: Record; diff --git a/src/plugins/index.d.ts b/src/plugins/index.d.ts index b1a4fa3ce5..035cc3d14e 100644 --- a/src/plugins/index.d.ts +++ b/src/plugins/index.d.ts @@ -1,7 +1,7 @@ -import Objects from './objects'; +import LiveObjects from './liveobjects'; import Push from './push'; export interface StandardPlugins { - Objects?: typeof Objects; + LiveObjects?: typeof LiveObjects; Push?: typeof Push; } diff --git a/src/plugins/objects/batchcontext.ts b/src/plugins/liveobjects/batchcontext.ts similarity index 99% rename from src/plugins/objects/batchcontext.ts rename to src/plugins/liveobjects/batchcontext.ts index 1f7858acc2..0a998faacc 100644 --- a/src/plugins/objects/batchcontext.ts +++ b/src/plugins/liveobjects/batchcontext.ts @@ -7,7 +7,7 @@ import type { Instance, Primitive, Value, -} from '../../../objects'; +} from '../../../liveobjects'; import { DefaultInstance } from './instance'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; diff --git a/src/plugins/objects/constants.ts b/src/plugins/liveobjects/constants.ts similarity index 100% rename from src/plugins/objects/constants.ts rename to src/plugins/liveobjects/constants.ts diff --git a/src/plugins/objects/defaults.ts b/src/plugins/liveobjects/defaults.ts similarity index 100% rename from src/plugins/objects/defaults.ts rename to src/plugins/liveobjects/defaults.ts diff --git a/src/plugins/objects/index.ts b/src/plugins/liveobjects/index.ts similarity index 82% rename from src/plugins/objects/index.ts rename to src/plugins/liveobjects/index.ts index bf0e7c2232..337a52f442 100644 --- a/src/plugins/objects/index.ts +++ b/src/plugins/liveobjects/index.ts @@ -12,9 +12,9 @@ export { }; /** - * The named Objects plugin object export to be passed to the Ably client. + * The named LiveObjects plugin object export to be passed to the Ably client. */ -export const Objects = { +export const LiveObjects = { LiveCounter: LiveCounterValueType, LiveMap: LiveMapValueType, ObjectMessage, diff --git a/src/plugins/objects/instance.ts b/src/plugins/liveobjects/instance.ts similarity index 99% rename from src/plugins/objects/instance.ts rename to src/plugins/liveobjects/instance.ts index 08e2f9c020..e49ef64a3f 100644 --- a/src/plugins/objects/instance.ts +++ b/src/plugins/liveobjects/instance.ts @@ -11,7 +11,7 @@ import type { LiveObject as LiveObjectType, Primitive, Value, -} from '../../../objects'; +} from '../../../liveobjects'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; import { LiveObject } from './liveobject'; diff --git a/src/plugins/objects/livecounter.ts b/src/plugins/liveobjects/livecounter.ts similarity index 99% rename from src/plugins/objects/livecounter.ts rename to src/plugins/liveobjects/livecounter.ts index 31db134f85..ae2e06edde 100644 --- a/src/plugins/objects/livecounter.ts +++ b/src/plugins/liveobjects/livecounter.ts @@ -1,5 +1,5 @@ import { __livetype } from '../../../ably'; -import { LiveCounter as PublicLiveCounter } from '../../../objects'; +import { LiveCounter as PublicLiveCounter } from '../../../liveobjects'; import { LiveObject, LiveObjectData, LiveObjectUpdate, LiveObjectUpdateNoop } from './liveobject'; import { ObjectData, ObjectMessage, ObjectOperation, ObjectOperationAction, ObjectsCounterOp } from './objectmessage'; import { RealtimeObject } from './realtimeobject'; diff --git a/src/plugins/objects/livecountervaluetype.ts b/src/plugins/liveobjects/livecountervaluetype.ts similarity index 98% rename from src/plugins/objects/livecountervaluetype.ts rename to src/plugins/liveobjects/livecountervaluetype.ts index 266c17fb84..456f3e4fe4 100644 --- a/src/plugins/objects/livecountervaluetype.ts +++ b/src/plugins/liveobjects/livecountervaluetype.ts @@ -1,5 +1,5 @@ import { __livetype } from '../../../ably'; -import { LiveCounter } from '../../../objects'; +import { LiveCounter } from '../../../liveobjects'; import { ObjectId } from './objectid'; import { createInitialValueJSONString, diff --git a/src/plugins/objects/livemap.ts b/src/plugins/liveobjects/livemap.ts similarity index 99% rename from src/plugins/objects/livemap.ts rename to src/plugins/liveobjects/livemap.ts index 2255c53905..b3d64c654a 100644 --- a/src/plugins/objects/livemap.ts +++ b/src/plugins/liveobjects/livemap.ts @@ -1,14 +1,14 @@ import { dequal } from 'dequal'; -import { __livetype } from '../../../ably'; +import { __livetype } from '../../../ably'; import { CompactedJsonValue, CompactedValue, + Primitive, LiveMap as PublicLiveMap, LiveObject as PublicLiveObject, - Primitive, Value, -} from '../../../objects'; +} from '../../../liveobjects'; import { LiveCounter } from './livecounter'; import { LiveCounterValueType } from './livecountervaluetype'; import { LiveMapValueType } from './livemapvaluetype'; diff --git a/src/plugins/objects/livemapvaluetype.ts b/src/plugins/liveobjects/livemapvaluetype.ts similarity index 98% rename from src/plugins/objects/livemapvaluetype.ts rename to src/plugins/liveobjects/livemapvaluetype.ts index cb40259f16..40ce2a860f 100644 --- a/src/plugins/objects/livemapvaluetype.ts +++ b/src/plugins/liveobjects/livemapvaluetype.ts @@ -1,5 +1,5 @@ import { __livetype } from '../../../ably'; -import { LiveMap as PublicLiveMap, Primitive, Value } from '../../../objects'; +import { Primitive, LiveMap as PublicLiveMap, Value } from '../../../liveobjects'; import { LiveCounterValueType } from './livecountervaluetype'; import { LiveMap, LiveMapObjectData, ObjectIdObjectData, ValueObjectData } from './livemap'; import { ObjectId } from './objectid'; diff --git a/src/plugins/objects/liveobject.ts b/src/plugins/liveobjects/liveobject.ts similarity index 100% rename from src/plugins/objects/liveobject.ts rename to src/plugins/liveobjects/liveobject.ts diff --git a/src/plugins/objects/objectid.ts b/src/plugins/liveobjects/objectid.ts similarity index 100% rename from src/plugins/objects/objectid.ts rename to src/plugins/liveobjects/objectid.ts diff --git a/src/plugins/objects/objectmessage.ts b/src/plugins/liveobjects/objectmessage.ts similarity index 99% rename from src/plugins/objects/objectmessage.ts rename to src/plugins/liveobjects/objectmessage.ts index 14caf71fda..c6cbda5af8 100644 --- a/src/plugins/objects/objectmessage.ts +++ b/src/plugins/liveobjects/objectmessage.ts @@ -2,7 +2,7 @@ import type BaseClient from 'common/lib/client/baseclient'; import type RealtimeChannel from 'common/lib/client/realtimechannel'; import type { MessageEncoding } from 'common/lib/types/basemessage'; import type * as Utils from 'common/lib/util/utils'; -import type * as ObjectsApi from '../../../objects'; +import type * as ObjectsApi from '../../../liveobjects'; const operationActions: ObjectsApi.ObjectOperationAction[] = [ 'map.create', diff --git a/src/plugins/objects/objectspool.ts b/src/plugins/liveobjects/objectspool.ts similarity index 97% rename from src/plugins/objects/objectspool.ts rename to src/plugins/liveobjects/objectspool.ts index 4c2fe021b2..f9f1fe7bd2 100644 --- a/src/plugins/objects/objectspool.ts +++ b/src/plugins/liveobjects/objectspool.ts @@ -118,7 +118,7 @@ export class ObjectsPool { const toDelete: string[] = []; for (const [objectId, obj] of this._pool.entries()) { // tombstoned objects should be removed from the pool if they have been tombstoned for longer than grace period. - // by removing them from the local pool, Objects plugin no longer keeps a reference to those objects, allowing JS's + // by removing them from the local pool, LiveObjects plugin no longer keeps a reference to those objects, allowing JS's // Garbage Collection to eventually free the memory for those objects, provided the user no longer references them either. if (obj.isTombstoned() && Date.now() - obj.tombstonedAt()! >= this._realtimeObject.gcGracePeriod) { toDelete.push(objectId); diff --git a/src/plugins/objects/pathobject.ts b/src/plugins/liveobjects/pathobject.ts similarity index 99% rename from src/plugins/objects/pathobject.ts rename to src/plugins/liveobjects/pathobject.ts index 9e1e9c2438..bc184f5344 100644 --- a/src/plugins/objects/pathobject.ts +++ b/src/plugins/liveobjects/pathobject.ts @@ -13,7 +13,7 @@ import type { PathObjectSubscriptionOptions, Primitive, Value, -} from '../../../objects'; +} from '../../../liveobjects'; import { DefaultInstance } from './instance'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; diff --git a/src/plugins/objects/pathobjectsubscriptionregister.ts b/src/plugins/liveobjects/pathobjectsubscriptionregister.ts similarity index 99% rename from src/plugins/objects/pathobjectsubscriptionregister.ts rename to src/plugins/liveobjects/pathobjectsubscriptionregister.ts index 2649aaaafb..68e2f22be5 100644 --- a/src/plugins/objects/pathobjectsubscriptionregister.ts +++ b/src/plugins/liveobjects/pathobjectsubscriptionregister.ts @@ -1,6 +1,6 @@ import type BaseClient from 'common/lib/client/baseclient'; import type { EventCallback, Subscription } from '../../../ably'; -import type { PathObjectSubscriptionEvent, PathObjectSubscriptionOptions } from '../../../objects'; +import type { PathObjectSubscriptionEvent, PathObjectSubscriptionOptions } from '../../../liveobjects'; import { ObjectMessage } from './objectmessage'; import { DefaultPathObject } from './pathobject'; import { RealtimeObject } from './realtimeobject'; diff --git a/src/plugins/objects/realtimeobject.ts b/src/plugins/liveobjects/realtimeobject.ts similarity index 99% rename from src/plugins/objects/realtimeobject.ts rename to src/plugins/liveobjects/realtimeobject.ts index bfa348b218..93e4166312 100644 --- a/src/plugins/objects/realtimeobject.ts +++ b/src/plugins/liveobjects/realtimeobject.ts @@ -2,7 +2,7 @@ import type BaseClient from 'common/lib/client/baseclient'; import type RealtimeChannel from 'common/lib/client/realtimechannel'; import type EventEmitter from 'common/lib/util/eventemitter'; import type { ChannelState } from '../../../ably'; -import type * as ObjectsApi from '../../../objects'; +import type * as ObjectsApi from '../../../liveobjects'; import { DEFAULTS } from './defaults'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; diff --git a/src/plugins/objects/rootbatchcontext.ts b/src/plugins/liveobjects/rootbatchcontext.ts similarity index 97% rename from src/plugins/objects/rootbatchcontext.ts rename to src/plugins/liveobjects/rootbatchcontext.ts index 4edbbee01a..ed93c0e32e 100644 --- a/src/plugins/objects/rootbatchcontext.ts +++ b/src/plugins/liveobjects/rootbatchcontext.ts @@ -1,4 +1,4 @@ -import type { Instance, Value } from '../../../objects'; +import type { Instance, Value } from '../../../liveobjects'; import { DefaultBatchContext } from './batchcontext'; import { ObjectMessage } from './objectmessage'; import { RealtimeObject } from './realtimeobject'; diff --git a/src/plugins/objects/syncobjectsdatapool.ts b/src/plugins/liveobjects/syncobjectsdatapool.ts similarity index 100% rename from src/plugins/objects/syncobjectsdatapool.ts rename to src/plugins/liveobjects/syncobjectsdatapool.ts diff --git a/test/common/globals/named_dependencies.js b/test/common/globals/named_dependencies.js index 5e9367ff45..db0d819e60 100644 --- a/test/common/globals/named_dependencies.js +++ b/test/common/globals/named_dependencies.js @@ -11,9 +11,9 @@ define(function () { browser: 'build/push', node: 'build/push', }, - objects: { - browser: 'build/objects', - node: 'build/objects', + liveobjects: { + browser: 'build/liveobjects', + node: 'build/liveobjects', }, // test modules @@ -27,9 +27,9 @@ define(function () { browser: 'test/common/modules/private_api_recorder', node: 'test/common/modules/private_api_recorder', }, - objects_helper: { - browser: 'test/common/modules/objects_helper', - node: 'test/common/modules/objects_helper', + liveobjects_helper: { + browser: 'test/common/modules/liveobjects_helper', + node: 'test/common/modules/liveobjects_helper', }, }); }); diff --git a/test/common/modules/objects_helper.js b/test/common/modules/liveobjects_helper.js similarity index 97% rename from test/common/modules/objects_helper.js rename to test/common/modules/liveobjects_helper.js index 744cbb8e70..3c0dc05bc2 100644 --- a/test/common/modules/objects_helper.js +++ b/test/common/modules/liveobjects_helper.js @@ -3,8 +3,8 @@ /** * Helper class to create pre-determined objects tree on channels and create object messages. */ -define(['ably', 'shared_helper', 'objects'], function (Ably, Helper, ObjectsPlugin) { - const createPM = Ably.makeProtocolMessageFromDeserialized({ ObjectsPlugin }); +define(['ably', 'shared_helper', 'liveobjects'], function (Ably, Helper, LiveObjectsPlugin) { + const createPM = Ably.makeProtocolMessageFromDeserialized({ LiveObjectsPlugin }); const ACTIONS = { MAP_CREATE: 0, @@ -27,7 +27,7 @@ define(['ably', 'shared_helper', 'objects'], function (Ably, Helper, ObjectsPlug return Helper.randomString(); } - class ObjectsHelper { + class LiveObjectsHelper { constructor(helper) { this._helper = helper; this._rest = helper.AblyRest({ useBinaryProtocol: false }); @@ -426,5 +426,5 @@ define(['ably', 'shared_helper', 'objects'], function (Ably, Helper, ObjectsPlug } } - return (module.exports = ObjectsHelper); + return (module.exports = LiveObjectsHelper); }); diff --git a/test/package/browser/template/README.md b/test/package/browser/template/README.md index e1653d7987..540571d13d 100644 --- a/test/package/browser/template/README.md +++ b/test/package/browser/template/README.md @@ -8,7 +8,7 @@ This directory is intended to be used for testing the following aspects of the a It contains three files, each of which import ably-js in different manners, and provide a way to briefly exercise its functionality: - `src/index-default.ts` imports the default ably-js package (`import { Realtime } from 'ably'`). -- `src/index-objects.ts` imports the Objects ably-js plugin (`import { Objects } from 'ably/objects'`). +- `src/index-liveobjects.ts` imports the LiveObjects ably-js plugin (`import { LiveObjects } from 'ably/liveobjects'`). - `src/index-modular.ts` imports the tree-shakable ably-js package (`import { BaseRealtime, WebSocketTransport, FetchRequest } from 'ably/modular'`). - `src/ReactApp.tsx` imports React hooks from the ably-js package (`import { useChannel } from 'ably/react'`). @@ -26,7 +26,7 @@ This directory exposes three package scripts that are to be used for testing: - `build`: Uses esbuild to create: 1. a bundle containing `src/index-default.ts` and ably-js; - 2. a bundle containing `src/index-objects.ts` and ably-js. + 2. a bundle containing `src/index-liveobjects.ts` and ably-js. 3. a bundle containing `src/index-modular.ts` and ably-js. - `test`: Using the bundles created by `build` and playwright components setup, tests that the code that exercises ably-js’s functionality is working correctly in a browser. - `typecheck`: Type-checks the code that imports ably-js. diff --git a/test/package/browser/template/package.json b/test/package/browser/template/package.json index 574da1a7b3..f2c023b6e6 100644 --- a/test/package/browser/template/package.json +++ b/test/package/browser/template/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "build": "esbuild --bundle src/index-default.ts --outdir=dist && esbuild --bundle src/index-objects.ts --outdir=dist && esbuild --bundle src/index-modular.ts --outdir=dist", + "build": "esbuild --bundle src/index-default.ts --outdir=dist && esbuild --bundle src/index-liveobjects.ts --outdir=dist && esbuild --bundle src/index-modular.ts --outdir=dist", "typecheck": "tsc --project src -noEmit", "test-support:server": "ts-node server/server.ts", "test": "npm run test:lib && npm run test:hooks", diff --git a/test/package/browser/template/server/resources/index-objects.html b/test/package/browser/template/server/resources/index-liveobjects.html similarity index 54% rename from test/package/browser/template/server/resources/index-objects.html rename to test/package/browser/template/server/resources/index-liveobjects.html index 44d594e83c..b7f284af5a 100644 --- a/test/package/browser/template/server/resources/index-objects.html +++ b/test/package/browser/template/server/resources/index-liveobjects.html @@ -2,10 +2,10 @@ - Ably NPM package test (Objects plugin export) + Ably NPM package test (LiveObjects plugin export) - + diff --git a/test/package/browser/template/server/server.ts b/test/package/browser/template/server/server.ts index 0cac0b7f18..faa1399f70 100644 --- a/test/package/browser/template/server/server.ts +++ b/test/package/browser/template/server/server.ts @@ -5,7 +5,7 @@ async function startWebServer(listenPort: number) { const server = express(); server.get('/', (req, res) => res.send('OK')); server.use(express.static(path.join(__dirname, '/resources'))); - for (const filename of ['index-default.js', 'index-objects.js', 'index-modular.js']) { + for (const filename of ['index-default.js', 'index-liveobjects.js', 'index-modular.js']) { server.use(`/${filename}`, express.static(path.join(__dirname, '..', 'dist', filename))); } diff --git a/test/package/browser/template/src/index-objects.ts b/test/package/browser/template/src/index-liveobjects.ts similarity index 93% rename from test/package/browser/template/src/index-objects.ts rename to test/package/browser/template/src/index-liveobjects.ts index f56112e3d5..1603637f47 100644 --- a/test/package/browser/template/src/index-objects.ts +++ b/test/package/browser/template/src/index-liveobjects.ts @@ -7,10 +7,10 @@ import { LiveCounterPathObject, LiveMap, LiveMapPathObject, + LiveObjects, ObjectMessage, - Objects, PathObject, -} from 'ably/objects'; +} from 'ably/liveobjects'; import { createSandboxAblyAPIKey } from './sandbox'; // Fix for "type 'typeof globalThis' has no index signature" error: @@ -38,12 +38,12 @@ type MyCustomObject = { globalThis.testAblyPackage = async function () { const key = await createSandboxAblyAPIKey(); - const realtime = new Realtime({ key, endpoint: 'nonprod:sandbox', plugins: { Objects } }); + const realtime = new Realtime({ key, endpoint: 'nonprod:sandbox', plugins: { LiveObjects } }); const channel = realtime.channels.get('channel', { modes: ['OBJECT_SUBSCRIBE', 'OBJECT_PUBLISH'] }); await channel.attach(); - // check Objects can be accessed on a channel with a custom type parameter. - // check that we can refer to the Objects types exported from 'ably/objects' by referencing a LiveMap interface. + // check LiveObjects can be accessed on a channel with a custom type parameter. + // check that we can refer to the LiveObjects types exported from 'ably/liveobjects' by referencing a LiveMap interface. const myObject: PathObject> = await channel.object.get(); // check entrypoint has expected LiveMap TypeScript type methods diff --git a/test/package/browser/template/test/lib/package.test.ts b/test/package/browser/template/test/lib/package.test.ts index 6c903d7f4a..8554dd762b 100644 --- a/test/package/browser/template/test/lib/package.test.ts +++ b/test/package/browser/template/test/lib/package.test.ts @@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test'; test.describe('NPM package', () => { for (const scenario of [ { name: 'default export', path: '/index-default.html' }, - { name: 'Objects plugin export', path: '/index-objects.html' }, + { name: 'LiveObjects plugin export', path: '/index-liveobjects.html' }, { name: 'modular export', path: '/index-modular.html' }, ]) { test.describe(scenario.name, () => { diff --git a/test/realtime/objects.test.js b/test/realtime/liveobjects.test.js similarity index 98% rename from test/realtime/objects.test.js rename to test/realtime/liveobjects.test.js index fe9094f84a..3492118e6a 100644 --- a/test/realtime/objects.test.js +++ b/test/realtime/liveobjects.test.js @@ -1,28 +1,28 @@ 'use strict'; -define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ( +define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], function ( Ably, Helper, chai, - ObjectsPlugin, - ObjectsHelper, + LiveObjectsPlugin, + LiveObjectsHelper, ) { const expect = chai.expect; const BufferUtils = Ably.Realtime.Platform.BufferUtils; const Utils = Ably.Realtime.Utils; const MessageEncoding = Ably.Realtime._MessageEncoding; - const createPM = Ably.makeProtocolMessageFromDeserialized({ ObjectsPlugin }); - const objectsFixturesChannel = 'objects_fixtures'; + const createPM = Ably.makeProtocolMessageFromDeserialized({ LiveObjectsPlugin }); + const liveobjectsFixturesChannel = 'liveobjects_fixtures'; const nextTick = Ably.Realtime.Platform.Config.nextTick; - const gcIntervalOriginal = ObjectsPlugin.RealtimeObject._DEFAULTS.gcInterval; - const LiveMap = ObjectsPlugin.LiveMap; - const LiveCounter = ObjectsPlugin.LiveCounter; + const gcIntervalOriginal = LiveObjectsPlugin.RealtimeObject._DEFAULTS.gcInterval; + const LiveMap = LiveObjectsPlugin.LiveMap; + const LiveCounter = LiveObjectsPlugin.LiveCounter; - function RealtimeWithObjects(helper, options) { - return helper.AblyRealtime({ ...options, plugins: { Objects: ObjectsPlugin } }); + function RealtimeWithLiveObjects(helper, options) { + return helper.AblyRealtime({ ...options, plugins: { LiveObjects: LiveObjectsPlugin } }); } - function channelOptionsWithObjects(options) { + function channelOptionsWithLiveObjects(options) { return { ...options, modes: ['OBJECT_SUBSCRIBE', 'OBJECT_PUBLISH'], @@ -92,7 +92,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function } function objectMessageFromValues(values) { - return ObjectsPlugin.ObjectMessage.fromValues(values, Utils, MessageEncoding); + return LiveObjectsPlugin.ObjectMessage.fromValues(values, Utils, MessageEncoding); } async function waitForMapKeyUpdate(mapInstance, key) { @@ -164,12 +164,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function } /** - * The channel with fixture data may not yet be populated by REST API requests made by ObjectsHelper. + * The channel with fixture data may not yet be populated by REST API requests made by LiveObjectsHelper. * This function waits for a channel to have all keys set. */ async function waitFixtureChannelIsReady(client) { - const channel = client.channels.get(objectsFixturesChannel, channelOptionsWithObjects()); - const expectedKeys = ObjectsHelper.fixtureRootKeys(); + const channel = client.channels.get(liveobjectsFixturesChannel, channelOptionsWithLiveObjects()); + const expectedKeys = LiveObjectsHelper.fixtureRootKeys(); await channel.attach(); const entryPathObject = await channel.object.get(); @@ -180,7 +180,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ); } - describe('realtime/objects', function () { + describe('realtime/liveobjects', function () { this.timeout(60 * 1000); before(function (done) { @@ -192,26 +192,26 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function return; } - new ObjectsHelper(helper) - .initForChannel(objectsFixturesChannel) + new LiveObjectsHelper(helper) + .initForChannel(liveobjectsFixturesChannel) .then(done) .catch((err) => done(err)); }); }); - describe('Realtime without Objects plugin', () => { + describe('Realtime without LiveObjects plugin', () => { /** @nospec */ it("throws an error when attempting to access the channel's `object` property", async function () { const helper = this.test.helper; const client = helper.AblyRealtime({ autoConnect: false }); const channel = client.channels.get('channel'); - expect(() => channel.object).to.throw('Objects plugin not provided'); + expect(() => channel.object).to.throw('LiveObjects plugin not provided'); }); /** @nospec */ it(`doesn't break when it receives an OBJECT ProtocolMessage`, async function () { const helper = this.test.helper; - const objectsHelper = new ObjectsHelper(helper); + const objectsHelper = new LiveObjectsHelper(helper); const testClient = helper.AblyRealtime(); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { @@ -243,7 +243,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function /** @nospec */ it(`doesn't break when it receives an OBJECT_SYNC ProtocolMessage`, async function () { const helper = this.test.helper; - const objectsHelper = new ObjectsHelper(helper); + const objectsHelper = new LiveObjectsHelper(helper); const testClient = helper.AblyRealtime(); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { @@ -277,11 +277,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }); }); - describe('Realtime with Objects plugin', () => { + describe('Realtime with LiveObjects plugin', () => { /** @nospec */ it("returns RealtimeObject class instance when accessing channel's `object` property", async function () { const helper = this.test.helper; - const client = RealtimeWithObjects(helper, { autoConnect: false }); + const client = RealtimeWithLiveObjects(helper, { autoConnect: false }); const channel = client.channels.get('channel'); expectInstanceOf(channel.object, 'RealtimeObject'); }); @@ -289,10 +289,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function /** @nospec */ it('RealtimeObject.get() returns LiveObject with id "root"', async function () { const helper = this.test.helper; - const client = RealtimeWithObjects(helper); + const client = RealtimeWithLiveObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get('channel', channelOptionsWithObjects()); + const channel = client.channels.get('channel', channelOptionsWithLiveObjects()); await channel.attach(); const entryPathObject = await channel.object.get(); @@ -304,10 +304,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function /** @nospec */ it('RealtimeObject.get() returns empty root when no objects exist on a channel', async function () { const helper = this.test.helper; - const client = RealtimeWithObjects(helper); + const client = RealtimeWithLiveObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get('channel', channelOptionsWithObjects()); + const channel = client.channels.get('channel', channelOptionsWithLiveObjects()); await channel.attach(); const entryPathObject = await channel.object.get(); @@ -319,10 +319,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function /** @nospec */ it('RealtimeObject.get() waits for initial OBJECT_SYNC to be completed before resolving', async function () { const helper = this.test.helper; - const client = RealtimeWithObjects(helper); + const client = RealtimeWithLiveObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get('channel', channelOptionsWithObjects()); + const channel = client.channels.get('channel', channelOptionsWithLiveObjects()); const getPromise = channel.object.get(); @@ -347,10 +347,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function /** @nospec */ it('RealtimeObject.get() resolves immediately when OBJECT_SYNC sequence is completed', async function () { const helper = this.test.helper; - const client = RealtimeWithObjects(helper); + const client = RealtimeWithLiveObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get('channel', channelOptionsWithObjects()); + const channel = client.channels.get('channel', channelOptionsWithLiveObjects()); await channel.attach(); // wait for sync sequence to complete by accessing root for the first time @@ -372,11 +372,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function /** @nospec */ it('RealtimeObject.get() waits for OBJECT_SYNC with empty cursor before resolving', async function () { const helper = this.test.helper; - const objectsHelper = new ObjectsHelper(helper); - const client = RealtimeWithObjects(helper); + const objectsHelper = new LiveObjectsHelper(helper); + const client = RealtimeWithLiveObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get('channel', channelOptionsWithObjects()); + const channel = client.channels.get('channel', channelOptionsWithLiveObjects()); await channel.attach(); // wait for initial sync sequence to complete @@ -432,10 +432,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function /** @nospec */ it('RealtimeObject.get() on unattached channel implicitly attaches and waits for sync', async function () { const helper = this.test.helper; - const client = RealtimeWithObjects(helper); + const client = RealtimeWithLiveObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get('channel', channelOptionsWithObjects()); + const channel = client.channels.get('channel', channelOptionsWithLiveObjects()); expect(channel.state).to.equal('initialized', 'Channel should be in initialized state'); // Set up a timeout to catch if get() hangs @@ -540,7 +540,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await waitFixtureChannelIsReady(client); - const channel = client.channels.get(objectsFixturesChannel, channelOptionsWithObjects()); + const channel = client.channels.get(liveobjectsFixturesChannel, channelOptionsWithLiveObjects()); await channel.attach(); const entryPathObject = await channel.object.get(); @@ -632,10 +632,10 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ]); // create a new client and check it syncs with the aggregated data - const client2 = RealtimeWithObjects(helper, clientOptions); + const client2 = RealtimeWithLiveObjects(helper, clientOptions); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel2 = client2.channels.get(channelName, channelOptionsWithObjects()); + const channel2 = client2.channels.get(channelName, channelOptionsWithLiveObjects()); await channel2.attach(); const pathObject2 = await channel2.object.get(); @@ -712,7 +712,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await waitFixtureChannelIsReady(client); - const channel = client.channels.get(objectsFixturesChannel, channelOptionsWithObjects()); + const channel = client.channels.get(liveobjectsFixturesChannel, channelOptionsWithLiveObjects()); await channel.attach(); const entryPathObject = await channel.object.get(); @@ -738,7 +738,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function await waitFixtureChannelIsReady(client); - const channel = client.channels.get(objectsFixturesChannel, channelOptionsWithObjects()); + const channel = client.channels.get(liveobjectsFixturesChannel, channelOptionsWithLiveObjects()); await channel.attach(); const entryPathObject = await channel.object.get(); @@ -6720,11 +6720,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ...instanceScenarios, ], async function (helper, scenario, clientOptions, channelName) { - const objectsHelper = new ObjectsHelper(helper); - const client = RealtimeWithObjects(helper, clientOptions); + const objectsHelper = new LiveObjectsHelper(helper); + const client = RealtimeWithLiveObjects(helper, clientOptions); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get(channelName, channelOptionsWithObjects()); + const channel = client.channels.get(channelName, channelOptionsWithLiveObjects()); const realtimeObject = channel.object; await channel.attach(); @@ -6990,7 +6990,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function action: async (ctx) => { const { channelName, sampleMapKey, sampleCounterKey, helper, entryPathObject, entryInstance } = ctx; const publishClientId = 'publish-clientId'; - const publishClient = RealtimeWithObjects(helper, { clientId: publishClientId }); + const publishClient = RealtimeWithLiveObjects(helper, { clientId: publishClientId }); // get the connection ID from the publish client once connected let publishConnectionId; @@ -7072,7 +7072,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ]); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const publishChannel = publishClient.channels.get(channelName, channelOptionsWithObjects()); + const publishChannel = publishClient.channels.get(channelName, channelOptionsWithLiveObjects()); await publishChannel.attach(); const publishRoot = await publishChannel.object.get(); @@ -7253,11 +7253,11 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function /** @nospec */ forScenarios(this, subscriptionCallbacksScenarios, async function (helper, scenario, clientOptions, channelName) { - const objectsHelper = new ObjectsHelper(helper); - const client = RealtimeWithObjects(helper, clientOptions); + const objectsHelper = new LiveObjectsHelper(helper); + const client = RealtimeWithLiveObjects(helper, clientOptions); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get(channelName, channelOptionsWithObjects()); + const channel = client.channels.get(channelName, channelOptionsWithLiveObjects()); await channel.attach(); const entryPathObject = await channel.object.get(); @@ -7300,12 +7300,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function it('gcGracePeriod is set from connectionDetails.objectsGCGracePeriod', async function () { const helper = this.test.helper; - const client = RealtimeWithObjects(helper); + const client = RealtimeWithLiveObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { await client.connection.once('connected'); - const channel = client.channels.get('channel', channelOptionsWithObjects()); + const channel = client.channels.get('channel', channelOptionsWithLiveObjects()); const realtimeObject = channel.object; const connectionManager = client.connection.connectionManager; const connectionDetails = connectionManager.connectionDetails; @@ -7339,7 +7339,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function helper.recordPrivateApi('listen.connectionManager.connectiondetails'); await connectionDetailsPromise; - // wait for next tick to ensure the connectionDetails event was processed by Objects plugin + // wait for next tick to ensure the connectionDetails event was processed by LiveObjects plugin await new Promise((res) => nextTick(res)); helper.recordPrivateApi('read.RealtimeObject.gcGracePeriod'); @@ -7349,12 +7349,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function it('gcGracePeriod has a default value if connectionDetails.objectsGCGracePeriod is missing', async function () { const helper = this.test.helper; - const client = RealtimeWithObjects(helper); + const client = RealtimeWithLiveObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { await client.connection.once('connected'); - const channel = client.channels.get('channel', channelOptionsWithObjects()); + const channel = client.channels.get('channel', channelOptionsWithLiveObjects()); const realtimeObject = channel.object; const connectionManager = client.connection.connectionManager; const connectionDetails = connectionManager.connectionDetails; @@ -7362,7 +7362,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function helper.recordPrivateApi('read.RealtimeObject._DEFAULTS.gcGracePeriod'); helper.recordPrivateApi('write.RealtimeObject.gcGracePeriod'); // set gcGracePeriod to a value different from the default - realtimeObject.gcGracePeriod = ObjectsPlugin.RealtimeObject._DEFAULTS.gcGracePeriod + 1; + realtimeObject.gcGracePeriod = LiveObjectsPlugin.RealtimeObject._DEFAULTS.gcGracePeriod + 1; const connectionDetailsPromise = connectionManager.once('connectiondetails'); @@ -7379,27 +7379,31 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function helper.recordPrivateApi('listen.connectionManager.connectiondetails'); await connectionDetailsPromise; - // wait for next tick to ensure the connectionDetails event was processed by Objects plugin + // wait for next tick to ensure the connectionDetails event was processed by LiveObjects plugin await new Promise((res) => nextTick(res)); helper.recordPrivateApi('read.RealtimeObject._DEFAULTS.gcGracePeriod'); helper.recordPrivateApi('read.RealtimeObject.gcGracePeriod'); expect(realtimeObject.gcGracePeriod).to.equal( - ObjectsPlugin.RealtimeObject._DEFAULTS.gcGracePeriod, + LiveObjectsPlugin.RealtimeObject._DEFAULTS.gcGracePeriod, 'Check gcGracePeriod is set to a default value if connectionDetails.objectsGCGracePeriod is missing', ); }, client); }); const tombstonesGCScenarios = [ - // for the next tests we need to access the private API of Objects plugin in order to verify that tombstoned entities were indeed deleted after the GC grace period. + // for the next tests we need to access the private API of LiveObjects plugin in order to verify that tombstoned entities were indeed deleted after the GC grace period. // public API hides that kind of information from the user and returns undefined for tombstoned entities even if realtime client still keeps a reference to them. { description: 'tombstoned object is removed from the pool after the GC grace period', action: async (ctx) => { const { objectsHelper, channelName, channel, realtimeObject, helper, waitForGCCycles, client } = ctx; - const counterCreatedPromise = waitForObjectOperation(helper, client, ObjectsHelper.ACTIONS.COUNTER_CREATE); + const counterCreatedPromise = waitForObjectOperation( + helper, + client, + LiveObjectsHelper.ACTIONS.COUNTER_CREATE, + ); // send a CREATE op, this adds an object to the pool const { objectId } = await objectsHelper.operationRequest( channelName, @@ -7500,13 +7504,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function forScenarios(this, tombstonesGCScenarios, async function (helper, scenario, clientOptions, channelName) { try { helper.recordPrivateApi('write.RealtimeObject._DEFAULTS.gcInterval'); - ObjectsPlugin.RealtimeObject._DEFAULTS.gcInterval = 500; + LiveObjectsPlugin.RealtimeObject._DEFAULTS.gcInterval = 500; - const objectsHelper = new ObjectsHelper(helper); - const client = RealtimeWithObjects(helper, clientOptions); + const objectsHelper = new LiveObjectsHelper(helper); + const client = RealtimeWithLiveObjects(helper, clientOptions); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get(channelName, channelOptionsWithObjects()); + const channel = client.channels.get(channelName, channelOptionsWithLiveObjects()); const realtimeObject = channel.object; await channel.attach(); @@ -7555,7 +7559,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function }, client); } finally { helper.recordPrivateApi('write.RealtimeObject._DEFAULTS.gcInterval'); - ObjectsPlugin.RealtimeObject._DEFAULTS.gcInterval = gcIntervalOriginal; + LiveObjectsPlugin.RealtimeObject._DEFAULTS.gcInterval = gcIntervalOriginal; } }); @@ -7760,13 +7764,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function /** @nospec */ forScenarios(this, clientConfigurationScenarios, async function (helper, scenario, clientOptions, channelName) { - const objectsHelper = new ObjectsHelper(helper); - const client = RealtimeWithObjects(helper, clientOptions); + const objectsHelper = new LiveObjectsHelper(helper); + const client = RealtimeWithLiveObjects(helper, clientOptions); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { // attach with correct channel modes so we can create Objects on the root for testing. // some scenarios will modify the underlying modes array to test specific behavior - const channel = client.channels.get(channelName, channelOptionsWithObjects()); + const channel = client.channels.get(channelName, channelOptionsWithLiveObjects()); const realtimeObject = channel.object; await channel.attach(); @@ -7805,7 +7809,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function */ it('object message publish respects connectionDetails.maxMessageSize', async function () { const helper = this.test.helper; - const client = RealtimeWithObjects(helper); + const client = RealtimeWithLiveObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { await client.connection.once('connected'); @@ -7831,7 +7835,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function helper.recordPrivateApi('listen.connectionManager.connectiondetails'); await connectionDetailsPromise; - const channel = client.channels.get('channel', channelOptionsWithObjects()); + const channel = client.channels.get('channel', channelOptionsWithLiveObjects()); await channel.attach(); const entryPathObject = await channel.object.get(); @@ -8148,7 +8152,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function /** @nospec */ forScenarios(this, objectMessageSizeScenarios, function (helper, scenario) { - const client = RealtimeWithObjects(helper, { autoConnect: false }); + const client = RealtimeWithLiveObjects(helper, { autoConnect: false }); helper.recordPrivateApi('call.ObjectMessage.encode'); const encodedMessage = scenario.message.encode(client); helper.recordPrivateApi('call.BufferUtils.utf8Encode'); // was called by a scenario to create buffers diff --git a/test/support/browser_file_list.js b/test/support/browser_file_list.js index c6cd5668c5..41c92692f7 100644 --- a/test/support/browser_file_list.js +++ b/test/support/browser_file_list.js @@ -39,7 +39,7 @@ window.__testFiles__.files = { 'test/realtime/failure.test.js': true, 'test/realtime/history.test.js': true, 'test/realtime/init.test.js': true, - 'test/realtime/objects.test.js': true, + 'test/realtime/liveobjects.test.js': true, 'test/realtime/message.test.js': true, 'test/realtime/presence.test.js': true, 'test/realtime/reauth.test.js': true, diff --git a/tsconfig.typedoc.json b/tsconfig.typedoc.json index 4f2342baa5..fbc3eb23ce 100644 --- a/tsconfig.typedoc.json +++ b/tsconfig.typedoc.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["src/platform/react-hooks", "test/package", "objects.d.mts"] + "exclude": ["src/platform/react-hooks", "test/package", "liveobjects.d.mts"] } diff --git a/typedoc.json b/typedoc.json index 027b9fcd28..3a7708bd4b 100644 --- a/typedoc.json +++ b/typedoc.json @@ -1,6 +1,6 @@ { "$schema": "https://typedoc.org/schema.json", - "entryPoints": ["ably.d.ts", "modular.d.ts", "objects.d.ts"], + "entryPoints": ["ably.d.ts", "modular.d.ts", "liveobjects.d.ts"], "tsconfig": "tsconfig.typedoc.json", "out": "typedoc/generated", "readme": "typedoc/landing-page.md", From 3d96faf3025b0e457ce77433ba473f6e4ad7f92b Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Thu, 18 Dec 2025 18:31:34 +0000 Subject: [PATCH 34/45] Fix bundle size and liveobjects tests added in https://github.com/ably/ably-js/pull/2121 --- scripts/moduleReport.ts | 2 +- test/realtime/liveobjects.test.js | 52 +++++++++++++++---------------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/scripts/moduleReport.ts b/scripts/moduleReport.ts index 3dae6fc282..c4acac7440 100644 --- a/scripts/moduleReport.ts +++ b/scripts/moduleReport.ts @@ -6,7 +6,7 @@ import { gzip } from 'zlib'; import Table from 'cli-table'; // The maximum size we allow for a minimal useful Realtime bundle (i.e. one that can subscribe to a channel) -const minimalUsefulRealtimeBundleSizeThresholdsKiB = { raw: 104, gzip: 32 }; +const minimalUsefulRealtimeBundleSizeThresholdsKiB = { raw: 105, gzip: 32 }; const baseClientNames = ['BaseRest', 'BaseRealtime']; diff --git a/test/realtime/liveobjects.test.js b/test/realtime/liveobjects.test.js index 3492118e6a..8a70db7a8d 100644 --- a/test/realtime/liveobjects.test.js +++ b/test/realtime/liveobjects.test.js @@ -22,7 +22,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f return helper.AblyRealtime({ ...options, plugins: { LiveObjects: LiveObjectsPlugin } }); } - function channelOptionsWithLiveObjects(options) { + function channelOptionsWithObjectModes(options) { return { ...options, modes: ['OBJECT_SUBSCRIBE', 'OBJECT_PUBLISH'], @@ -168,7 +168,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f * This function waits for a channel to have all keys set. */ async function waitFixtureChannelIsReady(client) { - const channel = client.channels.get(liveobjectsFixturesChannel, channelOptionsWithLiveObjects()); + const channel = client.channels.get(liveobjectsFixturesChannel, channelOptionsWithObjectModes()); const expectedKeys = LiveObjectsHelper.fixtureRootKeys(); await channel.attach(); @@ -292,7 +292,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f const client = RealtimeWithLiveObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get('channel', channelOptionsWithLiveObjects()); + const channel = client.channels.get('channel', channelOptionsWithObjectModes()); await channel.attach(); const entryPathObject = await channel.object.get(); @@ -307,7 +307,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f const client = RealtimeWithLiveObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get('channel', channelOptionsWithLiveObjects()); + const channel = client.channels.get('channel', channelOptionsWithObjectModes()); await channel.attach(); const entryPathObject = await channel.object.get(); @@ -322,7 +322,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f const client = RealtimeWithLiveObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get('channel', channelOptionsWithLiveObjects()); + const channel = client.channels.get('channel', channelOptionsWithObjectModes()); const getPromise = channel.object.get(); @@ -350,7 +350,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f const client = RealtimeWithLiveObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get('channel', channelOptionsWithLiveObjects()); + const channel = client.channels.get('channel', channelOptionsWithObjectModes()); await channel.attach(); // wait for sync sequence to complete by accessing root for the first time @@ -376,7 +376,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f const client = RealtimeWithLiveObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get('channel', channelOptionsWithLiveObjects()); + const channel = client.channels.get('channel', channelOptionsWithObjectModes()); await channel.attach(); // wait for initial sync sequence to complete @@ -435,7 +435,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f const client = RealtimeWithLiveObjects(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get('channel', channelOptionsWithLiveObjects()); + const channel = client.channels.get('channel', channelOptionsWithObjectModes()); expect(channel.state).to.equal('initialized', 'Channel should be in initialized state'); // Set up a timeout to catch if get() hangs @@ -540,7 +540,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f await waitFixtureChannelIsReady(client); - const channel = client.channels.get(liveobjectsFixturesChannel, channelOptionsWithLiveObjects()); + const channel = client.channels.get(liveobjectsFixturesChannel, channelOptionsWithObjectModes()); await channel.attach(); const entryPathObject = await channel.object.get(); @@ -635,7 +635,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f const client2 = RealtimeWithLiveObjects(helper, clientOptions); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel2 = client2.channels.get(channelName, channelOptionsWithLiveObjects()); + const channel2 = client2.channels.get(channelName, channelOptionsWithObjectModes()); await channel2.attach(); const pathObject2 = await channel2.object.get(); @@ -712,7 +712,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f await waitFixtureChannelIsReady(client); - const channel = client.channels.get(liveobjectsFixturesChannel, channelOptionsWithLiveObjects()); + const channel = client.channels.get(liveobjectsFixturesChannel, channelOptionsWithObjectModes()); await channel.attach(); const entryPathObject = await channel.object.get(); @@ -738,7 +738,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f await waitFixtureChannelIsReady(client); - const channel = client.channels.get(liveobjectsFixturesChannel, channelOptionsWithLiveObjects()); + const channel = client.channels.get(liveobjectsFixturesChannel, channelOptionsWithObjectModes()); await channel.attach(); const entryPathObject = await channel.object.get(); @@ -6724,7 +6724,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f const client = RealtimeWithLiveObjects(helper, clientOptions); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get(channelName, channelOptionsWithLiveObjects()); + const channel = client.channels.get(channelName, channelOptionsWithObjectModes()); const realtimeObject = channel.object; await channel.attach(); @@ -7072,7 +7072,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f ]); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const publishChannel = publishClient.channels.get(channelName, channelOptionsWithLiveObjects()); + const publishChannel = publishClient.channels.get(channelName, channelOptionsWithObjectModes()); await publishChannel.attach(); const publishRoot = await publishChannel.object.get(); @@ -7257,7 +7257,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f const client = RealtimeWithLiveObjects(helper, clientOptions); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get(channelName, channelOptionsWithLiveObjects()); + const channel = client.channels.get(channelName, channelOptionsWithObjectModes()); await channel.attach(); const entryPathObject = await channel.object.get(); @@ -7305,7 +7305,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f await helper.monitorConnectionThenCloseAndFinishAsync(async () => { await client.connection.once('connected'); - const channel = client.channels.get('channel', channelOptionsWithLiveObjects()); + const channel = client.channels.get('channel', channelOptionsWithObjectModes()); const realtimeObject = channel.object; const connectionManager = client.connection.connectionManager; const connectionDetails = connectionManager.connectionDetails; @@ -7354,7 +7354,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f await helper.monitorConnectionThenCloseAndFinishAsync(async () => { await client.connection.once('connected'); - const channel = client.channels.get('channel', channelOptionsWithLiveObjects()); + const channel = client.channels.get('channel', channelOptionsWithObjectModes()); const realtimeObject = channel.object; const connectionManager = client.connection.connectionManager; const connectionDetails = connectionManager.connectionDetails; @@ -7510,7 +7510,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f const client = RealtimeWithLiveObjects(helper, clientOptions); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel = client.channels.get(channelName, channelOptionsWithLiveObjects()); + const channel = client.channels.get(channelName, channelOptionsWithObjectModes()); const realtimeObject = channel.object; await channel.attach(); @@ -7770,7 +7770,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f await helper.monitorConnectionThenCloseAndFinishAsync(async () => { // attach with correct channel modes so we can create Objects on the root for testing. // some scenarios will modify the underlying modes array to test specific behavior - const channel = client.channels.get(channelName, channelOptionsWithLiveObjects()); + const channel = client.channels.get(channelName, channelOptionsWithObjectModes()); const realtimeObject = channel.object; await channel.attach(); @@ -7835,7 +7835,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f helper.recordPrivateApi('listen.connectionManager.connectiondetails'); await connectionDetailsPromise; - const channel = client.channels.get('channel', channelOptionsWithLiveObjects()); + const channel = client.channels.get('channel', channelOptionsWithObjectModes()); await channel.attach(); const entryPathObject = await channel.object.get(); @@ -8342,25 +8342,25 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f ]; forScenarios(this, syncEventsScenarios, async function (helper, scenario, clientOptions, channelName) { - const client = RealtimeWithObjects(helper, clientOptions); - const objectsHelper = new ObjectsHelper(helper); + const client = RealtimeWithLiveObjects(helper, clientOptions); + const objectsHelper = new LiveObjectsHelper(helper); await helper.monitorConnectionThenCloseAndFinishAsync(async () => { await client.connection.whenState('connected'); // Note that we don't attach the channel, so that the only ProtocolMessages the channel receives are those specified by the test scenario. - const channel = client.channels.get(channelName, channelOptionsWithObjects()); - const objects = channel.objects; + const channel = client.channels.get(channelName, channelOptionsWithObjectModes()); + const object = channel.object; // Track received sync events const receivedSyncEvents = []; // Subscribe to syncing and synced events - objects.on('syncing', () => { + object.on('syncing', () => { receivedSyncEvents.push('syncing'); }); - objects.on('synced', () => { + object.on('synced', () => { receivedSyncEvents.push('synced'); }); From 8bbf17346130aa00ae2b0630ddcf56ebe12cf7af Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Thu, 18 Dec 2025 19:49:03 +0000 Subject: [PATCH 35/45] Fix LiveObjects module augmentation for nodenext/node16 `moduleResolution` TypeScript projects using "moduleResolution": "nodenext" or "node16" were not seeing the `object` property on RealtimeChannel. This is because module augmentation with relative paths (declare module './ably') doesn't work correctly with these newer module resolution strategies - TypeScript resolves modules differently and fails to merge the augmented declarations. Fix by using the package name instead of a relative path in the module augmentation (declare module 'ably'). This works across all moduleResolution settings since TypeScript resolves the package name consistently regardless of the resolution strategy. --- liveobjects.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/liveobjects.d.ts b/liveobjects.d.ts index 700575b1df..9dad3588b4 100644 --- a/liveobjects.d.ts +++ b/liveobjects.d.ts @@ -1883,7 +1883,7 @@ export declare const LiveObjects: any; * importing from 'ably/liveobjects'. This ensures all LiveObjects types come from * the same module (CJS or ESM), avoiding type incompatibility issues. */ -declare module './ably' { +declare module 'ably' { interface RealtimeChannel { /** * A {@link RealtimeObject} object. From 918290d1ed68d08d72e223561a89426a06b2b1ff Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Thu, 18 Dec 2025 19:55:41 +0000 Subject: [PATCH 36/45] Fix TypeDoc error for "RealtimeChannel.object" link Fixes `Failed to resolve link to "RealtimeChannel.object" in comment for ably.CorePlugins.LiveObjects` TypeDoc error. RealtimeChannel.object is no longer defined in ably.d.ts so we can't link it, use backtick instead --- ably.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ably.d.ts b/ably.d.ts index 5d3553fc26..a98f50aa1e 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -636,7 +636,7 @@ export interface CorePlugins { Push?: unknown; /** - * A plugin which allows the client to use LiveObjects functionality at {@link RealtimeChannel.object}. + * A plugin which allows the client to use LiveObjects functionality at `RealtimeChannel.object`. */ LiveObjects?: unknown; } From 2d49166a9a7ce3b9b879018ee3a3cbd3f8aada4c Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Fri, 19 Dec 2025 09:16:16 +0000 Subject: [PATCH 37/45] Remove @experimental tag for LiveObjects API --- liveobjects.d.ts | 164 ----------------------------------------------- 1 file changed, 164 deletions(-) diff --git a/liveobjects.d.ts b/liveobjects.d.ts index 9dad3588b4..70597005f9 100644 --- a/liveobjects.d.ts +++ b/liveobjects.d.ts @@ -80,7 +80,6 @@ export declare interface RealtimeObject { * ``` * * @returns A promise which, upon success, will be fulfilled with a {@link PathObject}. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. - * @experimental */ get>(): Promise>>; @@ -90,7 +89,6 @@ export declare interface RealtimeObject { * @param event - The named event to listen for. * @param callback - The event listener. * @returns A {@link StatusSubscription} object that allows the provided listener to be deregistered from future updates. - * @experimental */ on(event: ObjectsEvent, callback: ObjectsEventCallback): StatusSubscription; @@ -99,14 +97,11 @@ export declare interface RealtimeObject { * * @param event - The named event. * @param callback - The event listener. - * @experimental */ off(event: ObjectsEvent, callback: ObjectsEventCallback): void; /** * Deregisters all registrations, for all events and listeners. - * - * @experimental */ offAll(): void; } @@ -250,8 +245,6 @@ interface PathObjectBase { * * Path segments with dots in them are escaped with a backslash. * For example, a path with segments `['a', 'b.c', 'd']` will be represented as `a.b\.c.d`. - * - * @experimental */ path(): string; @@ -272,7 +265,6 @@ interface PathObjectBase { * @param listener - An event listener function. * @param options - Optional subscription configuration. * @returns A {@link Subscription} object that allows the provided listener to be deregistered from future updates. - * @experimental */ subscribe( listener: EventCallback, @@ -288,7 +280,6 @@ interface PathObjectBase { * * @param options - Optional subscription configuration. * @returns An async iterator that yields {@link PathObjectSubscriptionEvent} objects. - * @experimental */ subscribeIterator(options?: PathObjectSubscriptionOptions): AsyncIterableIterator; } @@ -305,7 +296,6 @@ interface PathObjectCollectionMethods { * * @param path - A fully-qualified path string to navigate to, relative to the current path. * @returns A {@link PathObject} for the specified path. - * @experimental */ at(path: string): PathObject; } @@ -319,8 +309,6 @@ interface LiveMapPathObjectCollectionMethods = R * Each value is represented as a {@link PathObject} corresponding to its key. * * If the path does not resolve to a map object, returns an empty iterator. - * - * @experimental */ entries(): IterableIterator<[keyof T, PathObject]>; @@ -328,8 +316,6 @@ interface LiveMapPathObjectCollectionMethods = R * Returns an iterable of keys in the map at this path. * * If the path does not resolve to a map object, returns an empty iterator. - * - * @experimental */ keys(): IterableIterator; @@ -338,8 +324,6 @@ interface LiveMapPathObjectCollectionMethods = R * Each value is represented as a {@link PathObject}. * * If the path does not resolve to a map object, returns an empty iterator. - * - * @experimental */ values(): IterableIterator>; @@ -347,8 +331,6 @@ interface LiveMapPathObjectCollectionMethods = R * Returns the number of entries in the map at this path. * * If the path does not resolve to a map object, returns `undefined`. - * - * @experimental */ size(): number | undefined; } @@ -368,7 +350,6 @@ export interface LiveMapPathObject = Record(key: K): PathObject; @@ -377,7 +358,6 @@ export interface LiveMapPathObject = Record | undefined; @@ -390,8 +370,6 @@ export interface LiveMapPathObject = Record> | undefined; @@ -404,8 +382,6 @@ export interface LiveMapPathObject = Record> | undefined; } @@ -417,8 +393,6 @@ export interface LiveCounterPathObject extends PathObjectBase, LiveCounterOperat /** * Get the current value of the counter instance currently at this path. * If the path does not resolve to any specific instance, returns `undefined`. - * - * @experimental */ value(): number | undefined; @@ -427,7 +401,6 @@ export interface LiveCounterPathObject extends PathObjectBase, LiveCounterOperat * If the path does not resolve to any specific instance, returns `undefined`. * * @returns The {@link LiveCounterInstance} at this path, or `undefined` if none exists. - * @experimental */ instance(): LiveCounterInstance | undefined; @@ -436,8 +409,6 @@ export interface LiveCounterPathObject extends PathObjectBase, LiveCounterOperat * This is an alias for calling {@link LiveCounterPathObject.value | value()}. * * If the path does not resolve to any specific instance, returns `undefined`. - * - * @experimental */ compact(): CompactedValue | undefined; @@ -446,8 +417,6 @@ export interface LiveCounterPathObject extends PathObjectBase, LiveCounterOperat * This is an alias for calling {@link LiveCounterPathObject.value | value()}. * * If the path does not resolve to any specific instance, returns `undefined`. - * - * @experimental */ compactJson(): CompactedJsonValue | undefined; } @@ -459,8 +428,6 @@ export interface PrimitivePathObject extends Pa /** * Get the current value of the primitive currently at this path. * If the path does not resolve to any specific entry, returns `undefined`. - * - * @experimental */ value(): T | undefined; @@ -469,8 +436,6 @@ export interface PrimitivePathObject extends Pa * This is an alias for calling {@link PrimitivePathObject.value | value()}. * * If the path does not resolve to any specific entry, returns `undefined`. - * - * @experimental */ compact(): CompactedValue | undefined; @@ -479,8 +444,6 @@ export interface PrimitivePathObject extends Pa * Binary values are converted to base64-encoded strings. * * If the path does not resolve to any specific entry, returns `undefined`. - * - * @experimental */ compactJson(): CompactedJsonValue | undefined; } @@ -497,8 +460,6 @@ interface AnyPathObjectCollectionMethods { * Each value is represented as a {@link PathObject} corresponding to its key. * * If the path does not resolve to a map object, returns an empty iterator. - * - * @experimental */ entries>(): IterableIterator<[keyof T, PathObject]>; @@ -506,8 +467,6 @@ interface AnyPathObjectCollectionMethods { * Returns an iterable of keys in the map, if the path resolves to a {@link LiveMap}. * * If the path does not resolve to a map object, returns an empty iterator. - * - * @experimental */ keys>(): IterableIterator; @@ -516,8 +475,6 @@ interface AnyPathObjectCollectionMethods { * Each value is represented as a {@link PathObject}. * * If the path does not resolve to a map object, returns an empty iterator. - * - * @experimental */ values>(): IterableIterator>; @@ -525,8 +482,6 @@ interface AnyPathObjectCollectionMethods { * Returns the number of entries in the map, if the path resolves to a {@link LiveMap}. * * If the path does not resolve to a map object, returns `undefined`. - * - * @experimental */ size(): number | undefined; } @@ -549,15 +504,12 @@ export interface AnyPathObject * * @param key - A string key for the next path segment within the collection. * @returns A {@link PathObject} for the specified key. - * @experimental */ get(key: string): PathObject; /** * Get the current value of the LiveCounter or primitive currently at this path. * If the path does not resolve to any specific entry, returns `undefined`. - * - * @experimental */ value(): T | undefined; @@ -566,7 +518,6 @@ export interface AnyPathObject * If the path does not resolve to any specific instance, returns `undefined`. * * @returns The object instance at this path, or `undefined` if none exists. - * @experimental */ instance(): Instance | undefined; @@ -582,8 +533,6 @@ export interface AnyPathObject * If the path does not resolve to any specific entry, returns `undefined`. * * Use {@link AnyPathObject.compactJson | compactJson()} for a JSON-serializable representation. - * - * @experimental */ compact(): CompactedValue | undefined; @@ -597,8 +546,6 @@ export interface AnyPathObject * If the path does not resolve to any specific entry, returns `undefined`. * * Use {@link AnyPathObject.compact | compact()} for an in-memory representation. - * - * @experimental */ compactJson(): CompactedJsonValue | undefined; } @@ -607,8 +554,6 @@ export interface AnyPathObject * PathObject wraps a reference to a path starting from the entrypoint object on a channel. * The type parameter specifies the underlying type defined at that path, * and is used to infer the correct set of methods available for that type. - * - * @experimental */ export type PathObject = [T] extends [LiveMap] ? LiveMapPathObject @@ -627,8 +572,6 @@ interface BatchContextBase { * Get the object ID of the underlying instance. * * If the underlying instance at runtime is not a {@link LiveObject}, returns `undefined`. - * - * @experimental */ readonly id: string | undefined; } @@ -642,8 +585,6 @@ interface LiveMapBatchContextCollectionMethods = * Each value is represented as a {@link BatchContext} corresponding to its key. * * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental */ entries(): IterableIterator<[keyof T, BatchContext]>; @@ -651,8 +592,6 @@ interface LiveMapBatchContextCollectionMethods = * Returns an iterable of keys in the map. * * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental */ keys(): IterableIterator; @@ -661,8 +600,6 @@ interface LiveMapBatchContextCollectionMethods = * Each value is represented as a {@link BatchContext}. * * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental */ values(): IterableIterator>; @@ -670,8 +607,6 @@ interface LiveMapBatchContextCollectionMethods = * Returns the number of entries in the map. * * If the underlying instance at runtime is not a map, returns `undefined`. - * - * @experimental */ size(): number | undefined; } @@ -692,7 +627,6 @@ export interface LiveMapBatchContext = Record(key: K): BatchContext | undefined; @@ -705,8 +639,6 @@ export interface LiveMapBatchContext = Record> | undefined; @@ -719,8 +651,6 @@ export interface LiveMapBatchContext = Record> | undefined; } @@ -732,8 +662,6 @@ export interface LiveCounterBatchContext extends BatchContextBase, BatchContextL /** * Get the current value of the counter instance. * If the underlying instance at runtime is not a counter, returns `undefined`. - * - * @experimental */ value(): number | undefined; @@ -742,8 +670,6 @@ export interface LiveCounterBatchContext extends BatchContextBase, BatchContextL * This is an alias for calling {@link LiveCounterBatchContext.value | value()}. * * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * @experimental */ compact(): CompactedValue | undefined; @@ -752,8 +678,6 @@ export interface LiveCounterBatchContext extends BatchContextBase, BatchContextL * This is an alias for calling {@link LiveCounterBatchContext.value | value()}. * * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * @experimental */ compactJson(): CompactedJsonValue | undefined; } @@ -765,8 +689,6 @@ export interface PrimitiveBatchContext { /** * Get the underlying primitive value. * If the underlying instance at runtime is not a primitive value, returns `undefined`. - * - * @experimental */ value(): T | undefined; @@ -775,8 +697,6 @@ export interface PrimitiveBatchContext { * This is an alias for calling {@link PrimitiveBatchContext.value | value()}. * * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * @experimental */ compact(): CompactedValue | undefined; @@ -785,8 +705,6 @@ export interface PrimitiveBatchContext { * Binary values are converted to base64-encoded strings. * * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * @experimental */ compactJson(): CompactedJsonValue | undefined; } @@ -803,8 +721,6 @@ interface AnyBatchContextCollectionMethods { * Each value is represented as an {@link BatchContext} corresponding to its key. * * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental */ entries>(): IterableIterator<[keyof T, BatchContext]>; @@ -812,8 +728,6 @@ interface AnyBatchContextCollectionMethods { * Returns an iterable of keys in the map. * * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental */ keys>(): IterableIterator; @@ -822,8 +736,6 @@ interface AnyBatchContextCollectionMethods { * Each value is represented as a {@link BatchContext}. * * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental */ values>(): IterableIterator>; @@ -831,8 +743,6 @@ interface AnyBatchContextCollectionMethods { * Returns the number of entries in the map. * * If the underlying instance at runtime is not a map, returns `undefined`. - * - * @experimental */ size(): number | undefined; } @@ -857,7 +767,6 @@ export interface AnyBatchContext extends BatchContextBase, AnyBatchContextCollec * * @param key - The key to retrieve the value for. * @returns A {@link BatchContext} representing either a {@link LiveObject} or a primitive value (string, number, boolean, JSON-serializable object or array, or binary data), or `undefined` if the underlying instance at runtime is not a collection object, the key does not exist, the referenced {@link LiveObject} has been deleted, or this collection object itself has been deleted. - * @experimental */ get(key: string): BatchContext | undefined; @@ -867,7 +776,6 @@ export interface AnyBatchContext extends BatchContextBase, AnyBatchContextCollec * If the underlying instance at runtime is neither a counter nor a primitive value, returns `undefined`. * * @returns The current value of the underlying primitive or counter, or `undefined` if the value cannot be retrieved. - * @experimental */ value(): T | undefined; @@ -883,8 +791,6 @@ export interface AnyBatchContext extends BatchContextBase, AnyBatchContextCollec * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. * * Use {@link AnyBatchContext.compactJson | compactJson()} for a JSON-serializable representation. - * - * @experimental */ compact(): CompactedValue | undefined; @@ -898,8 +804,6 @@ export interface AnyBatchContext extends BatchContextBase, AnyBatchContextCollec * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. * * Use {@link AnyBatchContext.compact | compact()} for an in-memory representation. - * - * @experimental */ compactJson(): CompactedJsonValue | undefined; } @@ -911,8 +815,6 @@ export interface AnyBatchContext extends BatchContextBase, AnyBatchContextCollec * * The type parameter specifies the underlying type of the instance, * and is used to infer the correct set of methods available for that type. - * - * @experimental */ export type BatchContext = [T] extends [LiveMap] ? LiveMapBatchContext @@ -939,7 +841,6 @@ interface BatchContextLiveMapOperations = Record * * @param key - The key to set the value for. * @param value - The value to assign to the key. - * @experimental */ set(key: K, value: T[K]): void; @@ -955,7 +856,6 @@ interface BatchContextLiveMapOperations = Record * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. * * @param key - The key to remove. - * @experimental */ remove(key: keyof T & string): void; } @@ -976,7 +876,6 @@ interface BatchContextLiveCounterOperations { * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. * * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. - * @experimental */ increment(amount?: number): void; @@ -984,7 +883,6 @@ interface BatchContextLiveCounterOperations { * An alias for calling {@link BatchContextLiveCounterOperations.increment | increment(-amount)} * * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. - * @experimental */ decrement(amount?: number): void; } @@ -1008,7 +906,6 @@ interface BatchContextAnyOperations { * * @param key - The key to set the value for. * @param value - The value to assign to the key. - * @experimental */ set = Record>(key: keyof T & string, value: T[keyof T]): void; @@ -1024,7 +921,6 @@ interface BatchContextAnyOperations { * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. * * @param key - The key to remove. - * @experimental */ remove = Record>(key: keyof T & string): void; @@ -1042,7 +938,6 @@ interface BatchContextAnyOperations { * To get notified when object gets updated, use {@link PathObjectBase.subscribe | PathObject.subscribe} or {@link InstanceBase.subscribe | Instance.subscribe}, as appropriate. * * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. - * @experimental */ increment(amount?: number): void; @@ -1050,7 +945,6 @@ interface BatchContextAnyOperations { * An alias for calling {@link BatchContextAnyOperations.increment | increment(-amount)} * * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. - * @experimental */ decrement(amount?: number): void; } @@ -1074,7 +968,6 @@ interface BatchOperations { * * @param fn - A synchronous function that receives a {@link BatchContext} used to group operations together. * @returns A promise which resolves upon success of the batch operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental */ batch(fn: BatchFunction): Promise; } @@ -1099,7 +992,6 @@ interface LiveMapOperations = Record(key: K, value: T[K]): Promise; @@ -1117,7 +1009,6 @@ interface LiveMapOperations = Record; } @@ -1140,7 +1031,6 @@ interface LiveCounterOperations extends BatchOperations { * * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental */ increment(amount?: number): Promise; @@ -1149,7 +1039,6 @@ interface LiveCounterOperations extends BatchOperations { * * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental */ decrement(amount?: number): Promise; } @@ -1173,7 +1062,6 @@ interface AnyOperations { * * @param fn - A synchronous function that receives a {@link BatchContext} used to group operations together. * @returns A promise which resolves upon success of the batch operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental */ batch(fn: BatchFunction): Promise; @@ -1194,7 +1082,6 @@ interface AnyOperations { * @param key - The key to set the value for. * @param value - The value to assign to the key. * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental */ set = Record>(key: keyof T & string, value: T[keyof T]): Promise; @@ -1212,7 +1099,6 @@ interface AnyOperations { * * @param key - The key to remove. * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental */ remove = Record>(key: keyof T & string): Promise; @@ -1232,7 +1118,6 @@ interface AnyOperations { * * @param amount - The amount by which to increase the counter value. If not provided, defaults to 1. * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental */ increment(amount?: number): Promise; @@ -1241,7 +1126,6 @@ interface AnyOperations { * * @param amount - The amount by which to decrease the counter value. If not provided, defaults to 1. * @returns A promise which resolves upon success of the operation and rejects with an {@link ErrorInfo} object upon its failure. - * @experimental */ decrement(amount?: number): Promise; } @@ -1255,8 +1139,6 @@ interface InstanceBase { * Get the object ID of the underlying instance. * * If the underlying instance at runtime is not a {@link LiveObject}, returns `undefined`. - * - * @experimental */ readonly id: string | undefined; @@ -1278,7 +1160,6 @@ interface InstanceBase { * * @param listener - An event listener function. * @returns A {@link Subscription} object that allows the provided listener to be deregistered from future updates. - * @experimental */ subscribe(listener: EventCallback>): Subscription; @@ -1290,7 +1171,6 @@ interface InstanceBase { * but instead returns an async iterator that can be used in a `for await...of` loop for convenience. * * @returns An async iterator that yields {@link InstanceSubscriptionEvent} objects. - * @experimental */ subscribeIterator(): AsyncIterableIterator>; } @@ -1304,8 +1184,6 @@ interface LiveMapInstanceCollectionMethods = Rec * Each value is represented as an {@link Instance} corresponding to its key. * * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental */ entries(): IterableIterator<[keyof T, Instance]>; @@ -1313,8 +1191,6 @@ interface LiveMapInstanceCollectionMethods = Rec * Returns an iterable of keys in the map. * * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental */ keys(): IterableIterator; @@ -1323,8 +1199,6 @@ interface LiveMapInstanceCollectionMethods = Rec * Each value is represented as an {@link Instance}. * * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental */ values(): IterableIterator>; @@ -1332,8 +1206,6 @@ interface LiveMapInstanceCollectionMethods = Rec * Returns the number of entries in the map. * * If the underlying instance at runtime is not a map, returns `undefined`. - * - * @experimental */ size(): number | undefined; } @@ -1358,7 +1230,6 @@ export interface LiveMapInstance = Record(key: K): Instance | undefined; @@ -1371,8 +1242,6 @@ export interface LiveMapInstance = Record> | undefined; @@ -1385,8 +1254,6 @@ export interface LiveMapInstance = Record> | undefined; } @@ -1398,8 +1265,6 @@ export interface LiveCounterInstance extends InstanceBase, LiveCoun /** * Get the current value of the counter instance. * If the underlying instance at runtime is not a counter, returns `undefined`. - * - * @experimental */ value(): number | undefined; @@ -1408,8 +1273,6 @@ export interface LiveCounterInstance extends InstanceBase, LiveCoun * This is an alias for calling {@link LiveCounterInstance.value | value()}. * * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * @experimental */ compact(): CompactedValue | undefined; @@ -1418,8 +1281,6 @@ export interface LiveCounterInstance extends InstanceBase, LiveCoun * This is an alias for calling {@link LiveCounterInstance.value | value()}. * * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * @experimental */ compactJson(): CompactedJsonValue | undefined; } @@ -1434,8 +1295,6 @@ export interface PrimitiveInstance { * This reflects the value at the corresponding key in the collection at the time this instance was obtained. * * If the underlying instance at runtime is not a primitive value, returns `undefined`. - * - * @experimental */ value(): T | undefined; @@ -1444,8 +1303,6 @@ export interface PrimitiveInstance { * This is an alias for calling {@link PrimitiveInstance.value | value()}. * * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * @experimental */ compact(): CompactedValue | undefined; @@ -1454,8 +1311,6 @@ export interface PrimitiveInstance { * Binary values are converted to base64-encoded strings. * * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. - * - * @experimental */ compactJson(): CompactedJsonValue | undefined; } @@ -1472,8 +1327,6 @@ interface AnyInstanceCollectionMethods { * Each value is represented as an {@link Instance} corresponding to its key. * * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental */ entries>(): IterableIterator<[keyof T, Instance]>; @@ -1481,8 +1334,6 @@ interface AnyInstanceCollectionMethods { * Returns an iterable of keys in the map. * * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental */ keys>(): IterableIterator; @@ -1491,8 +1342,6 @@ interface AnyInstanceCollectionMethods { * Each value is represented as a {@link Instance}. * * If the underlying instance at runtime is not a map, returns an empty iterator. - * - * @experimental */ values>(): IterableIterator>; @@ -1500,8 +1349,6 @@ interface AnyInstanceCollectionMethods { * Returns the number of entries in the map. * * If the underlying instance at runtime is not a map, returns `undefined`. - * - * @experimental */ size(): number | undefined; } @@ -1526,7 +1373,6 @@ export interface AnyInstance extends InstanceBase, AnyInstan * * @param key - The key to get the child entry for. * @returns An {@link Instance} representing either a {@link LiveObject} or a primitive value (string, number, boolean, JSON-serializable object or array, or binary data), or `undefined` if the underlying instance at runtime is not a collection object, the key does not exist, the referenced {@link LiveObject} has been deleted, or this collection object itself has been deleted. - * @experimental */ get(key: string): Instance | undefined; @@ -1537,8 +1383,6 @@ export interface AnyInstance extends InstanceBase, AnyInstan * in the collection at the time this instance was obtained. * * If the underlying instance at runtime is neither a counter nor a primitive value, returns `undefined`. - * - * @experimental */ value(): T | undefined; @@ -1554,8 +1398,6 @@ export interface AnyInstance extends InstanceBase, AnyInstan * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. * * Use {@link AnyInstance.compactJson | compactJson()} for a JSON-serializable representation. - * - * @experimental */ compact(): CompactedValue | undefined; @@ -1569,8 +1411,6 @@ export interface AnyInstance extends InstanceBase, AnyInstan * If the underlying instance's value is not of the expected type at runtime, returns `undefined`. * * Use {@link AnyInstance.compact | compact()} for an in-memory representation. - * - * @experimental */ compactJson(): CompactedJsonValue | undefined; } @@ -1579,8 +1419,6 @@ export interface AnyInstance extends InstanceBase, AnyInstan * Instance wraps a specific object instance or entry in a specific collection object instance. * The type parameter specifies the underlying type of the instance, * and is used to infer the correct set of methods available for that type. - * - * @experimental */ export type Instance = [T] extends [LiveMap] ? LiveMapInstance @@ -1827,7 +1665,6 @@ export class LiveMap { * * @param initialEntries - Optional initial entries for the new LiveMap object. * @returns A {@link LiveMap} value type representing the initial state of the new LiveMap. - * @experimental */ static create>( // block TypeScript from inferring T from the initialEntries argument, so instead it is inferred @@ -1846,7 +1683,6 @@ export class LiveCounter { * * @param initialCount - Optional initial count for the new LiveCounter object. * @returns A {@link LiveCounter} value type representing the initial state of the new LiveCounter. - * @experimental */ static create(initialCount?: number): LiveCounter; } From 09ecb55fb5a92480a79f4df0d6164a3f0916c49f Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Fri, 19 Dec 2025 09:28:51 +0000 Subject: [PATCH 38/45] Remove RealtimeObject.offAll Just like .unsubscribeAll, .offAll doesn't play nice with composable SDKs - the end user by indiscriminately calling .offAll to remove all listeners may remove a listener registered by another SDK wrapper via the public subscription API. It is safer and more reliable to require developers to keep track of their listeners and remove them individually, which realistically they should be doing anyway. --- liveobjects.d.ts | 5 ----- src/plugins/liveobjects/realtimeobject.ts | 5 ----- 2 files changed, 10 deletions(-) diff --git a/liveobjects.d.ts b/liveobjects.d.ts index 70597005f9..874688a656 100644 --- a/liveobjects.d.ts +++ b/liveobjects.d.ts @@ -99,11 +99,6 @@ export declare interface RealtimeObject { * @param callback - The event listener. */ off(event: ObjectsEvent, callback: ObjectsEventCallback): void; - - /** - * Deregisters all registrations, for all events and listeners. - */ - offAll(): void; } /** diff --git a/src/plugins/liveobjects/realtimeobject.ts b/src/plugins/liveobjects/realtimeobject.ts index 93e4166312..5da34e0581 100644 --- a/src/plugins/liveobjects/realtimeobject.ts +++ b/src/plugins/liveobjects/realtimeobject.ts @@ -117,11 +117,6 @@ export class RealtimeObject { this._eventEmitterPublic.off(event, callback); } - offAll(): void { - // this public API method can be called without specific configuration, so checking for invalid settings is unnecessary. - this._eventEmitterPublic.off(); - } - /** * @internal */ From 96c39f9dfd6b7cbeeae2880f5dea24827a69df95 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Fri, 19 Dec 2025 09:39:56 +0000 Subject: [PATCH 39/45] Document RealtimeObject.get implicitly attaches now --- ably.d.ts | 2 +- liveobjects.d.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/ably.d.ts b/ably.d.ts index a98f50aa1e..bd596e4a0d 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2501,7 +2501,7 @@ export declare interface RealtimeChannel extends EventEmitter = (ctx: BatchContext) => void export declare interface RealtimeObject { /** * Retrieves a {@link PathObject} for the object on a channel. + * Implicitly {@link RealtimeChannel.attach | attaches to the channel} if not already attached. * * A type parameter can be provided to describe the structure of the Objects on the channel. * From a7462b14fd8ccbfa60f92e561468434ec5070ba7 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Fri, 19 Dec 2025 10:29:58 +0000 Subject: [PATCH 40/45] Handle channel configuration checks on PathObject/Instance level instead of LiveMap/LiveCounter --- src/plugins/liveobjects/batchcontext.ts | 11 +- src/plugins/liveobjects/instance.ts | 24 ++++ src/plugins/liveobjects/livecounter.ts | 3 - src/plugins/liveobjects/livemap.ts | 8 -- src/plugins/liveobjects/liveobject.ts | 2 - src/plugins/liveobjects/pathobject.ts | 26 ++++ test/realtime/liveobjects.test.js | 154 +++++++++++++----------- 7 files changed, 138 insertions(+), 90 deletions(-) diff --git a/src/plugins/liveobjects/batchcontext.ts b/src/plugins/liveobjects/batchcontext.ts index 0a998faacc..d96646ca87 100644 --- a/src/plugins/liveobjects/batchcontext.ts +++ b/src/plugins/liveobjects/batchcontext.ts @@ -25,6 +25,11 @@ export class DefaultBatchContext implements AnyBatchContext { this._client = this._realtimeObject.getClient(); } + get id(): string | undefined { + this._throwIfClosed(); + return this._instance.id; + } + get(key: string): BatchContext | undefined { this._realtimeObject.throwIfInvalidAccessApiConfiguration(); this._throwIfClosed(); @@ -53,12 +58,6 @@ export class DefaultBatchContext implements AnyBatchContext { return this._instance.compactJson(); } - get id(): string | undefined { - this._realtimeObject.throwIfInvalidAccessApiConfiguration(); - this._throwIfClosed(); - return this._instance.id; - } - *entries>(): IterableIterator<[keyof T, BatchContext]> { this._realtimeObject.throwIfInvalidAccessApiConfiguration(); this._throwIfClosed(); diff --git a/src/plugins/liveobjects/instance.ts b/src/plugins/liveobjects/instance.ts index e49ef64a3f..785ffd2c9c 100644 --- a/src/plugins/liveobjects/instance.ts +++ b/src/plugins/liveobjects/instance.ts @@ -50,6 +50,8 @@ export class DefaultInstance implements AnyInstance { * Use compactJson() for a JSON-serializable representation. */ compact(): CompactedValue | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + if (this._value instanceof LiveMap) { return this._value.compact() as CompactedValue; } @@ -64,6 +66,8 @@ export class DefaultInstance implements AnyInstance { * Use compact() for an in-memory representation. */ compactJson(): CompactedJsonValue | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + if (this._value instanceof LiveMap) { return this._value.compactJson() as CompactedJsonValue; } @@ -78,6 +82,8 @@ export class DefaultInstance implements AnyInstance { } get(key: string): Instance | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + if (!(this._value instanceof LiveMap)) { // can't get a key from a non-LiveMap type return undefined; @@ -95,6 +101,8 @@ export class DefaultInstance implements AnyInstance { } value(): U | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + if (this._value instanceof LiveObject) { if (this._value instanceof LiveCounter) { return this._value.value() as U; @@ -125,6 +133,8 @@ export class DefaultInstance implements AnyInstance { } *entries>(): IterableIterator<[keyof U, Instance]> { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + if (!(this._value instanceof LiveMap)) { // return empty iterator for non-LiveMap objects return; @@ -137,6 +147,8 @@ export class DefaultInstance implements AnyInstance { } *keys>(): IterableIterator { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + if (!(this._value instanceof LiveMap)) { // return empty iterator for non-LiveMap objects return; @@ -152,6 +164,8 @@ export class DefaultInstance implements AnyInstance { } size(): number | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + if (!(this._value instanceof LiveMap)) { // can't return size for non-LiveMap objects return undefined; @@ -163,6 +177,7 @@ export class DefaultInstance implements AnyInstance { key: keyof U & string, value: U[keyof U], ): Promise { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); if (!(this._value instanceof LiveMap)) { throw new this._client.ErrorInfo('Cannot set a key on a non-LiveMap instance', 92007, 400); } @@ -170,6 +185,7 @@ export class DefaultInstance implements AnyInstance { } remove = Record>(key: keyof U & string): Promise { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); if (!(this._value instanceof LiveMap)) { throw new this._client.ErrorInfo('Cannot remove a key from a non-LiveMap instance', 92007, 400); } @@ -177,6 +193,7 @@ export class DefaultInstance implements AnyInstance { } increment(amount?: number | undefined): Promise { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); if (!(this._value instanceof LiveCounter)) { throw new this._client.ErrorInfo('Cannot increment a non-LiveCounter instance', 92007, 400); } @@ -184,6 +201,7 @@ export class DefaultInstance implements AnyInstance { } decrement(amount?: number | undefined): Promise { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); if (!(this._value instanceof LiveCounter)) { throw new this._client.ErrorInfo('Cannot decrement a non-LiveCounter instance', 92007, 400); } @@ -191,9 +209,12 @@ export class DefaultInstance implements AnyInstance { } subscribe(listener: EventCallback>): Subscription { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + if (!(this._value instanceof LiveObject)) { throw new this._client.ErrorInfo('Cannot subscribe to a non-LiveObject instance', 92007, 400); } + return this._value.subscribe((event: InstanceEvent) => { listener({ object: this as unknown as Instance, @@ -203,9 +224,12 @@ export class DefaultInstance implements AnyInstance { } subscribeIterator(): AsyncIterableIterator> { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + if (!(this._value instanceof LiveObject)) { throw new this._client.ErrorInfo('Cannot subscribe to a non-LiveObject instance', 92007, 400); } + return this._client.Utils.listenerToAsyncIterator((listener) => { const { unsubscribe } = this.subscribe(listener); return unsubscribe; diff --git a/src/plugins/liveobjects/livecounter.ts b/src/plugins/liveobjects/livecounter.ts index ae2e06edde..255ec92156 100644 --- a/src/plugins/liveobjects/livecounter.ts +++ b/src/plugins/liveobjects/livecounter.ts @@ -66,7 +66,6 @@ export class LiveCounter extends LiveObject /** @spec RTLC5 */ value(): number { - this._realtimeObject.throwIfInvalidAccessApiConfiguration(); // RTLC5a, RTLC5b return this._dataRef.data; // RTLC5c } @@ -80,7 +79,6 @@ export class LiveCounter extends LiveObject * @returns A promise which resolves upon receiving the ACK message for the published operation message. */ async increment(amount: number): Promise { - this._realtimeObject.throwIfInvalidWriteApiConfiguration(); const msg = LiveCounter.createCounterIncMessage(this._realtimeObject, this.getObjectId(), amount); return this._realtimeObject.publish([msg]); } @@ -89,7 +87,6 @@ export class LiveCounter extends LiveObject * An alias for calling {@link LiveCounter.increment | LiveCounter.increment(-amount)} */ async decrement(amount: number): Promise { - this._realtimeObject.throwIfInvalidWriteApiConfiguration(); // do an explicit type safety check here before negating the amount value, // so we don't unintentionally change the type sent by a user if (typeof amount !== 'number' || !Number.isFinite(amount)) { diff --git a/src/plugins/liveobjects/livemap.ts b/src/plugins/liveobjects/livemap.ts index b3d64c654a..d4d502a251 100644 --- a/src/plugins/liveobjects/livemap.ts +++ b/src/plugins/liveobjects/livemap.ts @@ -203,8 +203,6 @@ export class LiveMap = Record> */ // force the key to be of type string as we only allow strings as key in a map get(key: TKey): T[TKey] | undefined { - this._realtimeObject.throwIfInvalidAccessApiConfiguration(); // RTLM5b, RTLM5c - if (this.isTombstoned()) { return undefined; } @@ -226,8 +224,6 @@ export class LiveMap = Record> } size(): number { - this._realtimeObject.throwIfInvalidAccessApiConfiguration(); - let size = 0; for (const value of this._dataRef.data.values()) { if (this._isMapEntryTombstoned(value)) { @@ -242,8 +238,6 @@ export class LiveMap = Record> } *entries(): IterableIterator<[TKey, T[TKey]]> { - this._realtimeObject.throwIfInvalidAccessApiConfiguration(); - for (const [key, entry] of this._dataRef.data.entries()) { if (this._isMapEntryTombstoned(entry)) { // do not return tombstoned entries @@ -281,7 +275,6 @@ export class LiveMap = Record> key: TKey, value: T[TKey] | LiveCounterValueType | LiveMapValueType, ): Promise { - this._realtimeObject.throwIfInvalidWriteApiConfiguration(); const msgs = await LiveMap.createMapSetMessage(this._realtimeObject, this.getObjectId(), key, value); return this._realtimeObject.publish(msgs); } @@ -296,7 +289,6 @@ export class LiveMap = Record> * @returns A promise which resolves upon receiving the ACK message for the published operation message. */ async remove(key: TKey): Promise { - this._realtimeObject.throwIfInvalidWriteApiConfiguration(); const msg = LiveMap.createMapRemoveMessage(this._realtimeObject, this.getObjectId(), key); return this._realtimeObject.publish([msg]); } diff --git a/src/plugins/liveobjects/liveobject.ts b/src/plugins/liveobjects/liveobject.ts index b4f671bc73..55858d9735 100644 --- a/src/plugins/liveobjects/liveobject.ts +++ b/src/plugins/liveobjects/liveobject.ts @@ -69,8 +69,6 @@ export abstract class LiveObject< } subscribe(listener: EventCallback): Subscription { - this._realtimeObject.throwIfInvalidAccessApiConfiguration(); - this._subscriptions.on(LiveObjectSubscriptionEvent.updated, listener); const unsubscribe = () => { diff --git a/src/plugins/liveobjects/pathobject.ts b/src/plugins/liveobjects/pathobject.ts index bc184f5344..c18b9af18f 100644 --- a/src/plugins/liveobjects/pathobject.ts +++ b/src/plugins/liveobjects/pathobject.ts @@ -59,6 +59,8 @@ export class DefaultPathObject implements AnyPathObject { * Use compactJson() for a JSON-serializable representation. */ compact(): CompactedValue | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + try { const resolved = this._resolvePath(this._path); @@ -85,6 +87,8 @@ export class DefaultPathObject implements AnyPathObject { * Use compact() for an in-memory representation. */ compactJson(): CompactedJsonValue | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + try { const resolved = this._resolvePath(this._path); @@ -171,6 +175,8 @@ export class DefaultPathObject implements AnyPathObject { * If the path does not resolve to any specific entry, returns `undefined`. */ value(): U | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + try { const resolved = this._resolvePath(this._path); @@ -212,6 +218,8 @@ export class DefaultPathObject implements AnyPathObject { } instance(): Instance | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + try { return this._resolveInstance(); } catch (error) { @@ -228,6 +236,8 @@ export class DefaultPathObject implements AnyPathObject { * Returns an iterator of [key, value] pairs for LiveMap entries */ *entries>(): IterableIterator<[keyof U, PathObject]> { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + try { const resolved = this._resolvePath(this._path); if (!(resolved instanceof LiveMap)) { @@ -255,6 +265,8 @@ export class DefaultPathObject implements AnyPathObject { * Returns an iterator of keys for LiveMap entries */ *keys>(): IterableIterator { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + try { const resolved = this._resolvePath(this._path); if (!(resolved instanceof LiveMap)) { @@ -286,6 +298,8 @@ export class DefaultPathObject implements AnyPathObject { * Returns the size of the collection at this path */ size(): number | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + try { const resolved = this._resolvePath(this._path); if (!(resolved instanceof LiveMap)) { @@ -308,6 +322,8 @@ export class DefaultPathObject implements AnyPathObject { key: keyof T & string, value: T[keyof T], ): Promise { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); + const resolved = this._resolvePath(this._path); if (!(resolved instanceof LiveMap)) { throw new this._client.ErrorInfo( @@ -321,6 +337,8 @@ export class DefaultPathObject implements AnyPathObject { } remove = Record>(key: keyof T & string): Promise { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); + const resolved = this._resolvePath(this._path); if (!(resolved instanceof LiveMap)) { throw new this._client.ErrorInfo( @@ -334,6 +352,8 @@ export class DefaultPathObject implements AnyPathObject { } increment(amount?: number): Promise { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); + const resolved = this._resolvePath(this._path); if (!(resolved instanceof LiveCounter)) { throw new this._client.ErrorInfo( @@ -347,6 +367,8 @@ export class DefaultPathObject implements AnyPathObject { } decrement(amount?: number): Promise { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); + const resolved = this._resolvePath(this._path); if (!(resolved instanceof LiveCounter)) { throw new this._client.ErrorInfo( @@ -381,10 +403,12 @@ export class DefaultPathObject implements AnyPathObject { listener: EventCallback, options?: PathObjectSubscriptionOptions, ): Subscription { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); return this._realtimeObject.getPathObjectSubscriptionRegister().subscribe(this._path, listener, options ?? {}); } subscribeIterator(options?: PathObjectSubscriptionOptions): AsyncIterableIterator { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); return this._client.Utils.listenerToAsyncIterator((listener) => { const { unsubscribe } = this.subscribe(listener, options); return unsubscribe; @@ -392,6 +416,8 @@ export class DefaultPathObject implements AnyPathObject { } async batch(fn: BatchFunction): Promise { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); + const instance = this._resolveInstance(); if (!instance) { throw new this._client.ErrorInfo( diff --git a/test/realtime/liveobjects.test.js b/test/realtime/liveobjects.test.js index 8a70db7a8d..d4d89591a4 100644 --- a/test/realtime/liveobjects.test.js +++ b/test/realtime/liveobjects.test.js @@ -3651,11 +3651,12 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f savedCtx = ctx; }); - expectBatchContextAccessApiToThrow({ + checkBatchContextAccessApiErrors({ ctx: savedCtx, errorMsg: 'Batch is closed', + skipId: true, }); - expectBatchContextWriteApiToThrow({ + checkBatchContextWriteApiErrors({ ctx: savedCtx, errorMsg: 'Batch is closed', }); @@ -3679,11 +3680,12 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f } expect(caughtError, 'Check batch call failed with an error').to.exist; - expectBatchContextAccessApiToThrow({ + checkBatchContextAccessApiErrors({ ctx: savedCtx, errorMsg: 'Batch is closed', + skipId: true, }); - expectBatchContextWriteApiToThrow({ + checkBatchContextWriteApiErrors({ ctx: savedCtx, errorMsg: 'Batch is closed', }); @@ -7563,44 +7565,59 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f } }); - const expectAccessApiToThrow = async ({ realtimeObject, map, counter, errorMsg }) => { - expect(() => counter.value()).to.throw(errorMsg); - - expect(() => map.get('key')).to.throw(errorMsg); - expect(() => map.size()).to.throw(errorMsg); - expect(() => [...map.entries()]).to.throw(errorMsg); - expect(() => [...map.keys()]).to.throw(errorMsg); - expect(() => [...map.values()]).to.throw(errorMsg); - - for (const obj of [map, counter]) { - expect(() => obj.subscribe()).to.throw(errorMsg); - // TODO: replace with instance/pathobject .unsubscribe() call - // expect(() => obj.unsubscribe(() => {})).not.to.throw(); // this should not throw - } + const checkAccessApiErrors = async ({ entryPathObject, entryInstance, errorMsg }) => { + // PathObject + expect(() => entryPathObject.path()).not.to.throw(); // this should not throw + expect(() => entryPathObject.compact()).to.throw(errorMsg); + expect(() => entryPathObject.compactJson()).to.throw(errorMsg); + expect(() => entryPathObject.get('key')).not.to.throw(); // this should not throw + expect(() => entryPathObject.at('path')).not.to.throw(); // this should not throw + expect(() => entryPathObject.value()).to.throw(errorMsg); + expect(() => entryPathObject.instance()).to.throw(errorMsg); + expect(() => [...entryPathObject.entries()]).to.throw(errorMsg); + expect(() => [...entryPathObject.keys()]).to.throw(errorMsg); + expect(() => [...entryPathObject.values()]).to.throw(errorMsg); + expect(() => entryPathObject.size()).to.throw(errorMsg); + expect(() => entryPathObject.subscribe()).to.throw(errorMsg); + expect(() => [...entryPathObject.subscribeIterator()]).to.throw(errorMsg); + + // Instance + expect(() => entryInstance.id).not.to.throw(); // this should not throw + expect(() => entryInstance.compact()).to.throw(errorMsg); + expect(() => entryInstance.compactJson()).to.throw(errorMsg); + expect(() => entryInstance.get()).to.throw(errorMsg); + expect(() => entryInstance.value()).to.throw(errorMsg); + expect(() => [...entryInstance.entries()]).to.throw(errorMsg); + expect(() => [...entryInstance.keys()]).to.throw(errorMsg); + expect(() => [...entryInstance.values()]).to.throw(errorMsg); + expect(() => entryInstance.size()).to.throw(errorMsg); + expect(() => entryInstance.subscribe()).to.throw(errorMsg); + expect(() => [...entryInstance.subscribeIterator()]).to.throw(errorMsg); }; - const expectWriteApiToThrow = async ({ entryInstance, map, counter, errorMsg }) => { + const checkWriteApiErrors = async ({ entryPathObject, entryInstance, errorMsg }) => { + // PathObject + await expectToThrowAsync(async () => entryPathObject.set(), errorMsg); + await expectToThrowAsync(async () => entryPathObject.remove(), errorMsg); + await expectToThrowAsync(async () => entryPathObject.increment(), errorMsg); + await expectToThrowAsync(async () => entryPathObject.decrement(), errorMsg); + await expectToThrowAsync(async () => entryPathObject.batch(), errorMsg); + + // Instance + await expectToThrowAsync(async () => entryInstance.set(), errorMsg); + await expectToThrowAsync(async () => entryInstance.remove(), errorMsg); + await expectToThrowAsync(async () => entryInstance.increment(), errorMsg); + await expectToThrowAsync(async () => entryInstance.decrement(), errorMsg); await expectToThrowAsync(async () => entryInstance.batch(), errorMsg); - - await expectToThrowAsync(async () => counter.increment(), errorMsg); - await expectToThrowAsync(async () => counter.decrement(), errorMsg); - - await expectToThrowAsync(async () => map.set(), errorMsg); - await expectToThrowAsync(async () => map.remove(), errorMsg); - - for (const obj of [map, counter]) { - // TODO: replace with instance/pathobject .unsubscribe() call - // expect(() => obj.unsubscribe(() => {})).not.to.throw(); // this should not throw - } }; /** Make sure to call this inside the batch method as batch objects can't be interacted with outside the batch callback */ - const expectBatchContextAccessApiToThrow = ({ ctx, errorMsg }) => { + const checkBatchContextAccessApiErrors = ({ ctx, errorMsg, skipId }) => { + if (!skipId) expect(() => ctx.id).not.to.throw(); // this should not throw expect(() => ctx.get()).to.throw(errorMsg); expect(() => ctx.value()).to.throw(errorMsg); expect(() => ctx.compact()).to.throw(errorMsg); expect(() => ctx.compactJson()).to.throw(errorMsg); - expect(() => ctx.id).to.throw(errorMsg); expect(() => [...ctx.entries()]).to.throw(errorMsg); expect(() => [...ctx.keys()]).to.throw(errorMsg); expect(() => [...ctx.values()]).to.throw(errorMsg); @@ -7608,7 +7625,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f }; /** Make sure to call this inside the batch method as batch objects can't be interacted with outside the batch callback */ - const expectBatchContextWriteApiToThrow = ({ ctx, errorMsg }) => { + const checkBatchContextWriteApiErrors = ({ ctx, errorMsg }) => { expect(() => ctx.set()).to.throw(errorMsg); expect(() => ctx.remove()).to.throw(errorMsg); expect(() => ctx.increment()).to.throw(errorMsg); @@ -7619,20 +7636,20 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f { description: 'public API throws missing object modes error when attached without correct modes', action: async (ctx) => { - const { realtimeObject, entryInstance, channel, map, counter } = ctx; + const { realtimeObject, entryPathObject, entryInstance, channel } = ctx; // obtain batch context with valid modes first await entryInstance.batch((ctx) => { // now simulate missing modes channel.modes = []; - expectBatchContextAccessApiToThrow({ ctx, errorMsg: '"object_subscribe" channel mode' }); - expectBatchContextWriteApiToThrow({ ctx, errorMsg: '"object_publish" channel mode' }); + checkBatchContextAccessApiErrors({ ctx, errorMsg: '"object_subscribe" channel mode' }); + checkBatchContextWriteApiErrors({ ctx, errorMsg: '"object_publish" channel mode' }); }); await expectToThrowAsync(async () => realtimeObject.get(), '"object_subscribe" channel mode'); - await expectAccessApiToThrow({ realtimeObject, map, counter, errorMsg: '"object_subscribe" channel mode' }); - await expectWriteApiToThrow({ entryInstance, map, counter, errorMsg: '"object_publish" channel mode' }); + await checkAccessApiErrors({ entryPathObject, entryInstance, errorMsg: '"object_subscribe" channel mode' }); + await checkWriteApiErrors({ entryPathObject, entryInstance, errorMsg: '"object_publish" channel mode' }); }, }, @@ -7640,7 +7657,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f description: 'public API throws missing object modes error when not yet attached but client options are missing correct modes', action: async (ctx) => { - const { realtimeObject, entryInstance, channel, map, counter, helper } = ctx; + const { realtimeObject, entryPathObject, entryInstance, channel, helper } = ctx; // obtain batch context with valid modes first await entryInstance.batch((ctx) => { @@ -7649,20 +7666,20 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f helper.recordPrivateApi('write.channel.channelOptions.modes'); channel.channelOptions.modes = []; - expectBatchContextAccessApiToThrow({ ctx, errorMsg: '"object_subscribe" channel mode' }); - expectBatchContextWriteApiToThrow({ ctx, errorMsg: '"object_publish" channel mode' }); + checkBatchContextAccessApiErrors({ ctx, errorMsg: '"object_subscribe" channel mode' }); + checkBatchContextWriteApiErrors({ ctx, errorMsg: '"object_publish" channel mode' }); }); await expectToThrowAsync(async () => realtimeObject.get(), '"object_subscribe" channel mode'); - await expectAccessApiToThrow({ realtimeObject, map, counter, errorMsg: '"object_subscribe" channel mode' }); - await expectWriteApiToThrow({ entryInstance, map, counter, errorMsg: '"object_publish" channel mode' }); + await checkAccessApiErrors({ entryPathObject, entryInstance, errorMsg: '"object_subscribe" channel mode' }); + await checkWriteApiErrors({ entryPathObject, entryInstance, errorMsg: '"object_publish" channel mode' }); }, }, { description: 'public API throws invalid channel state error when channel DETACHED', action: async (ctx) => { - const { realtimeObject, entryInstance, channel, map, counter, helper } = ctx; + const { entryPathObject, entryInstance, channel, helper } = ctx; // obtain batch context with valid channel state first await entryInstance.batch((ctx) => { @@ -7670,20 +7687,18 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f helper.recordPrivateApi('call.channel.requestState'); channel.requestState('detached'); - expectBatchContextAccessApiToThrow({ ctx, errorMsg: 'failed as channel state is detached' }); - expectBatchContextWriteApiToThrow({ ctx, errorMsg: 'failed as channel state is detached' }); + checkBatchContextAccessApiErrors({ ctx, errorMsg: 'failed as channel state is detached' }); + checkBatchContextWriteApiErrors({ ctx, errorMsg: 'failed as channel state is detached' }); }); - await expectAccessApiToThrow({ - realtimeObject, - map, - counter, + await checkAccessApiErrors({ + entryPathObject, + entryInstance, errorMsg: 'failed as channel state is detached', }); - await expectWriteApiToThrow({ + await checkWriteApiErrors({ + entryPathObject, entryInstance, - map, - counter, errorMsg: 'failed as channel state is detached', }); }, @@ -7692,7 +7707,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f { description: 'public API throws invalid channel state error when channel FAILED', action: async (ctx) => { - const { realtimeObject, entryInstance, channel, map, counter, helper } = ctx; + const { realtimeObject, entryPathObject, entryInstance, channel, helper } = ctx; // obtain batch context with valid channel state first await entryInstance.batch((ctx) => { @@ -7700,21 +7715,19 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f helper.recordPrivateApi('call.channel.requestState'); channel.requestState('failed'); - expectBatchContextAccessApiToThrow({ ctx, errorMsg: 'failed as channel state is failed' }); - expectBatchContextWriteApiToThrow({ ctx, errorMsg: 'failed as channel state is failed' }); + checkBatchContextAccessApiErrors({ ctx, errorMsg: 'failed as channel state is failed' }); + checkBatchContextWriteApiErrors({ ctx, errorMsg: 'failed as channel state is failed' }); }); await expectToThrowAsync(async () => realtimeObject.get(), 'failed as channel state is failed'); - await expectAccessApiToThrow({ - realtimeObject, - map, - counter, + await checkAccessApiErrors({ + entryPathObject, + entryInstance, errorMsg: 'failed as channel state is failed', }); - await expectWriteApiToThrow({ + await checkWriteApiErrors({ + entryPathObject, entryInstance, - map, - counter, errorMsg: 'failed as channel state is failed', }); }, @@ -7723,7 +7736,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f { description: 'public write API throws invalid channel state error when channel SUSPENDED', action: async (ctx) => { - const { entryInstance, channel, map, counter, helper } = ctx; + const { entryPathObject, entryInstance, channel, helper } = ctx; // obtain batch context with valid channel state first await entryInstance.batch((ctx) => { @@ -7731,13 +7744,12 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f helper.recordPrivateApi('call.channel.requestState'); channel.requestState('suspended'); - expectBatchContextWriteApiToThrow({ ctx, errorMsg: 'failed as channel state is suspended' }); + checkBatchContextWriteApiErrors({ ctx, errorMsg: 'failed as channel state is suspended' }); }); - await expectWriteApiToThrow({ + await checkWriteApiErrors({ + entryPathObject, entryInstance, - map, - counter, errorMsg: 'failed as channel state is suspended', }); }, @@ -7746,7 +7758,7 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f { description: 'public write API throws invalid channel option when "echoMessages" is disabled', action: async (ctx) => { - const { entryInstance, client, map, counter, helper } = ctx; + const { client, entryPathObject, entryInstance, helper } = ctx; // obtain batch context with valid client options first await entryInstance.batch((ctx) => { @@ -7754,10 +7766,10 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f helper.recordPrivateApi('write.realtime.options.echoMessages'); client.options.echoMessages = false; - expectBatchContextWriteApiToThrow({ ctx, errorMsg: '"echoMessages" client option' }); + checkBatchContextWriteApiErrors({ ctx, errorMsg: '"echoMessages" client option' }); }); - await expectWriteApiToThrow({ entryInstance, map, counter, errorMsg: '"echoMessages" client option' }); + await checkWriteApiErrors({ entryPathObject, entryInstance, errorMsg: '"echoMessages" client option' }); }, }, ]; From 0e2063f583beec321c067eb363c306eab0073634 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Wed, 10 Dec 2025 02:20:18 +0000 Subject: [PATCH 41/45] Remove unnecessary OnObjectsEventResponse --- src/plugins/liveobjects/realtimeobject.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/plugins/liveobjects/realtimeobject.ts b/src/plugins/liveobjects/realtimeobject.ts index 5da34e0581..7dc1edfb9e 100644 --- a/src/plugins/liveobjects/realtimeobject.ts +++ b/src/plugins/liveobjects/realtimeobject.ts @@ -1,7 +1,7 @@ import type BaseClient from 'common/lib/client/baseclient'; import type RealtimeChannel from 'common/lib/client/realtimechannel'; import type EventEmitter from 'common/lib/util/eventemitter'; -import type { ChannelState } from '../../../ably'; +import type { ChannelState, StatusSubscription } from '../../../ably'; import type * as ObjectsApi from '../../../liveobjects'; import { DEFAULTS } from './defaults'; import { LiveCounter } from './livecounter'; @@ -32,10 +32,6 @@ const StateToEventsMap: Record = { export type ObjectsEventCallback = () => void; -export interface OnObjectsEventResponse { - off(): void; -} - export class RealtimeObject { gcGracePeriod: number; @@ -95,7 +91,7 @@ export class RealtimeObject { return pathObject; } - on(event: ObjectsEvent, callback: ObjectsEventCallback): OnObjectsEventResponse { + on(event: ObjectsEvent, callback: ObjectsEventCallback): StatusSubscription { // this public API method can be called without specific configuration, so checking for invalid settings is unnecessary. this._eventEmitterPublic.on(event, callback); From a72adebb3c29a62434e86c8e826944db7e850d00 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Wed, 10 Dec 2025 02:20:12 +0000 Subject: [PATCH 42/45] LiveObjects Path-based API migration guide Part of AIT-31 - docs for LiveObjects path-based API --- docs/migration-guides/v2/liveobjects.md | 793 ++++++++++++++++++++++++ 1 file changed, 793 insertions(+) create mode 100644 docs/migration-guides/v2/liveobjects.md diff --git a/docs/migration-guides/v2/liveobjects.md b/docs/migration-guides/v2/liveobjects.md new file mode 100644 index 0000000000..86b167af63 --- /dev/null +++ b/docs/migration-guides/v2/liveobjects.md @@ -0,0 +1,793 @@ +# LiveObjects migration guide for ably-js v2.16 + +## Overview + +ably-js v2.16 introduces significant improvements to the LiveObjects API, centered around a new path-based interaction model using `PathObject`. While these are breaking changes, they provide a more intuitive and robust experience. + +**Key improvements:** + +- **Path-based operations**: Interact with nested objects through paths that automatically resolve at runtime +- **Resilient subscriptions**: Subscribe to paths rather than specific object instances, making subscriptions resilient to object replacements +- **Simplified object creation**: Create deeply nested object structures in a single operation without explicit management of child objects or risk of creating orphaned objects + +Here's how to migrate your LiveObjects usage to the new PathObject-based API introduced in ably-js v2.16: + +1. [Understand `PathObject`](#understand-pathobject). +2. [Update to v2.16 or later and handle breaking changes](#update-to-v216-or-later-and-handle-breaking-changes). +3. (Optional) [Take advantage of new Objects features that v2.16 introduces](#take-advantage-of-new-objects-features-that-v216-introduces). +4. (Optional) Check out [common migration patterns](#common-migration-patterns) for a quick reference. + +## Understand `PathObject` + +The core concept in the new API is the `PathObject`. Unlike the previous API where you worked directly with `LiveMap` and `LiveCounter` instances, a `PathObject` represents a **path to a location** within your channel's object hierarchy. + +**Why path-based?** The previous instance-based approach had several limitations: + +- Traversing object hierarchy required explicit checks for nulls to check if an object exists +- Instance-level subscriptions broke when an object at a path was replaced with a new instance +- Instance-level subscriptions for collection types lacked the ability to subscribe and receive updates for nested child objects + +With `PathObject`, operations are evaluated against the current value at a path **when the operation is invoked**, not when the `PathObject` is created. This makes your code more resilient to changes in the object structure. + +You can still access the specific underlying `Instance` using [`PathObject.instance()`](#access-explicit-object-instances-using-instance) when needed. + +## Update to v2.16 or later and handle breaking changes + +Begin by updating to ably-js version 2.16.0 or later. + +Now, you need to address the breaking changes introduced by v2.16. Here we explain how. + +The changes below are split into: + +- [general changes](#general-changes) +- [changes that only affect TypeScript users](#only-typescript-users) + +### General changes + +#### Update the entrypoint: `channel.objects` → `channel.object` + +The API entrypoint has changed from plural `channel.objects` to singular `channel.object`, reflecting the single entry object per channel model. + +**Before:** + +```typescript +const channelObjects = channel.objects; +``` + +**After:** + +```typescript +const channelObject = channel.object; +``` + +#### Replace `getRoot()` with `get()` and use `PathObject` + +The `objects.getRoot()` method has been replaced with `object.get()`, which returns a `PathObject` representing the entrypoint for your channel's object hierarchy. + +**Before:** + +```typescript +// root is a LiveMap instance +const root = await channel.objects.getRoot(); + +const childEntry = root.get('child'); // returns a LiveMap, LiveCounter, or a Primitive value +``` + +**After:** + +```typescript +// myObject is a PathObject +const myObject = await channel.object.get(); + +const childPathObject = myObject.get('child'); // returns a PathObject for a "child" path +``` + +**Access nested paths with `PathObject`:** + +```typescript +// Chain .get() calls to navigate nested structures +const shape = myObject.get('shape'); +const colour = myObject.get('shape').get('colour'); +const border = myObject.get('shape').get('colour').get('border'); + +// Or use .at() to get a PathObject for a fully-qualified string path +const border = myObject.at('shape.colour.border'); + +// Call .path() to get a fully-qualified string path for a location +const path = myObject.get('shape').get('colour').get('border').path(); // shape.colour.border +``` + +**Understand `PathObject` runtime resolution:** + +The key difference with `PathObject` is that **operations resolve the path at runtime** when the method is called. This means: + +- **Obtaining a `PathObject` never fails** - even if nothing exists at that path yet: + + ```typescript + const shape = myObject.get('shape'); // Always succeeds, even if 'shape' doesn't exist + ``` + +- **Access methods return empty defaults** when the path doesn't resolve to an appropriate object at runtime: + + ```typescript + // If 'visits' doesn't exist or isn't a primitive or a LiveCounter + const visits = myObject.get('visits').value(); // undefined + + // If 'players' doesn't exist or isn't a LiveMap + for (const [key, player] of myObject.get('players').entries()) { + // Empty iterator - loop body never executes + } + ``` + +- **Mutation methods throw errors** when the path doesn't resolve to an appropriate object at runtime: + + ```typescript + // If 'visits' doesn't exist at all + await myObject.get('visits').increment(1); + // Throws: path resolution error - Could not resolve value at path + + // If 'visits' exists but is not LiveCounter + await myObject.get('visits').increment(1); + // Throws: operation error - Cannot increment a non-LiveCounter object + ``` + +This design enables you to safely create PathObjects and use access methods without extensive error checking, while mutation methods will fail fast if the path or type is incorrect at runtime. + +#### Retrieve primitive values using `.value()` + +Since `PathObject` and `Instance` now wrap underlying LiveObjects and primitive values, you need to call `.value()` to retrieve the actual value of a primitive or a LiveCounter. + +**Before:** + +```typescript +const root = await channel.objects.getRoot(); +const name = root.get('name'); // 'Alice' +``` + +**After:** + +```typescript +const myObject = await channel.object.get(); +const name = myObject.get('name').value(); // 'Alice' +// For counters, .value() returns the counter's numeric value +const visitsCount = myObject.get('visits').value(); // 42 +``` + +**Note:** `.value()` returns `undefined` if the value at the path is not a primitive or a LiveCounter. + +#### Create objects using static `LiveCounter.create()` and `LiveMap.create()` methods + +The `channel.objects.createCounter()` and `channel.objects.createMap()` methods have been removed. To create new objects, use the static factory methods `LiveCounter.create()` and `LiveMap.create()` to define the initial data, then pass the returned value types to mutation methods when setting a value in a collection. + +**Before:** + +```typescript +const root = await channel.objects.getRoot(); +const counter = await channel.objects.createCounter(0); +const map = await channel.objects.createMap({ name: 'Alice' }); +await root.set('visits', counter); +await root.set('user', map); +``` + +**After:** + +```typescript +import { LiveCounter, LiveMap } from 'ably/objects'; + +const myObject = await channel.object.get(); +await myObject.set('visits', LiveCounter.create(0)); +await myObject.set('user', LiveMap.create({ name: 'Alice' })); +``` + +**Create deeply nested structures:** + +These static factory methods enable you to create entire nested structures in a single operation: + +**Before:** + +```typescript +const root = await channel.objects.getRoot(); +const colour = await channel.objects.createMap({ border: 'red', fill: 'blue' }); +const shape = await channel.objects.createMap({ name: 'circle', radius: 10, colour }); +await root.set('shape', shape); +``` + +**After:** + +```typescript +const myObject = await channel.object.get(); +await myObject.set( + 'shape', + LiveMap.create({ + name: 'circle', + radius: 10, + colour: LiveMap.create({ + border: 'red', + fill: 'blue', + }), + }), +); +``` + +**Note:** `LiveMap.create()` and `LiveCounter.create()` return value types that describe the initial data for an object to be created when assigned to a collection. The actual LiveObject is created during the assignment operation. If you reuse the same value type in multiple assignments, each assignment will create a distinct LiveObject with its own unique object ID, rather than pointing to the same object: + +```typescript +const myObject = await channel.object.get(); + +const counterValue = LiveCounter.create(0); +await myObject.set('visits', counterValue); // Creates LiveCounter A with ID "counter:abc..." +await myObject.set('downloads', counterValue); // Creates LiveCounter B with ID "counter:xyz..." +// Result: Two separate LiveCounter objects, each with different IDs +``` + +#### Update subscription signatures to receive operation context + +The subscription callback signature has changed to provide more complete information. Previously, callbacks received a partial update object with limited operation metadata. Now, callbacks receive a structured context containing: + +1. **`message`**: The complete `ObjectMessage` that carried the operation that led to the change +2. **`object`**: A reference to the updated `PathObject` or `Instance`, particularly useful for [deep subscriptions](#path-based-subscriptions-with-depth) to identify which nested object changed + +**Before:** + +```typescript +const root = await channel.objects.getRoot(); +const counter = root.get('visits'); +counter.subscribe((update) => { + // update: { update: { amount: 5 }, clientId: 'my-client-id', connectionId: '...' } + console.log('Counter changed by:', update.update.amount); +}); + +const shape = root.get('shape'); +shape.subscribe((update) => { + // update: { update: { "colour": "updated", "size": "removed" }, clientId: 'my-client-id', connectionId: '...' } + console.log('Map changed:', update); +}); +``` + +**After:** + +```typescript +const myObject = await channel.object.get(); + +myObject.get('visits').subscribe(({ object, message }) => { + // object: PathObject representing the updated path + // message: ObjectMessage that carried the operation that led to the change, if applicable + console.log('Updated path:', object.path()); + console.log('Operation:', message.operation); + console.log('Client ID:', message.clientId); + console.log('Connection ID:', message.connectionId); +}); +``` + +##### Path-based subscriptions with depth + +Subscriptions on a `PathObject` can now observe changes at any depth below a path. The `.subscribe()` method now accepts an options object to configure the subscription depth: + +```typescript +// Subscribe to all changes within myObject - infinite depth (default behavior) +myObject.subscribe(({ object, message }) => { + console.log('Something changed at:', object.path()); +}); + +// Subscribe only to changes on this object - depth 1 +myObject.subscribe( + ({ object, message }) => { + console.log('This object changed:', object.path()); + }, + { depth: 1 }, +); +``` + +#### Stop using lifecycle event subscriptions on LiveObject + +LiveObjects no longer provide lifecycle events API for `deleted` events. Instead, deleted events are emitted via the regular subscription flow. As a result, LiveObject `.on()`, `.off()`, and `.offAll()` methods have been removed. + +The `deleted` lifecycle event is now observable via regular subscriptions by checking `ObjectMessage.operation.action` equals `object.delete`. + +**Before:** + +```typescript +const root = await channel.objects.getRoot(); +const shape = root.get('shape'); + +// Subscribe to 'deleted' lifecycle event +shape.on('deleted', () => { + console.log('Object was deleted'); +}); +``` + +**After:** + +```typescript +const myObject = await channel.object.get(); + +// Subscribe to changes and check for delete operations +myObject.get('shape').subscribe(({ object, message }) => { + if (message?.operation.action === 'object.delete') { + console.log('Object was deleted'); + } +}); +``` + +#### Replace `unsubscribeAll()` with individual subscription management + +The `unsubscribeAll()` method has been removed from LiveObject subscriptions. Instead, use the `unsubscribe()` method on individual `Subscription` objects returned by `.subscribe()` to deregister specific listeners. + +**Before:** + +```typescript +const root = await channel.objects.getRoot(); +const visits = root.get('visits'); + +visits.subscribe((update) => console.log('Update 1', update)); +visits.subscribe((update) => console.log('Update 2', update)); + +// Unsubscribe all listeners at once +visits.unsubscribeAll(); +``` + +**After:** + +```typescript +const myObject = await channel.object.get(); +const visits = myObject.get('visits'); + +const subscription1 = visits.subscribe(({ object, message }) => console.log('Update 1', message)); +const subscription2 = visits.subscribe(({ object, message }) => console.log('Update 2', message)); + +// Unsubscribe each listener individually +subscription1.unsubscribe(); +subscription2.unsubscribe(); +``` + +#### Change usage of `objects.batch()` to `PathObject.batch()`/`Instance.batch()` + +The batch API, previously available at `channel.objects.batch()`, is now available as a `.batch()` method on any `PathObject` or `Instance` instead. It now supports object creation inside a batch function. + +The batch context has the same API as the `Instance` class, except for `batch()` itself, with one key difference: **all mutation methods are synchronous**, just like in the previous version of `.batch()`. + +**Before:** + +```typescript +// Object creation was not supported in batch, objects had to be created before calling the .batch() method +const counter = await channel.objects.createCounter(100); + +// Batch can only be called on channel.objects +await channel.objects.batch((ctx) => { + const root = ctx.getRoot(); + root.set('name', 'Alice'); + root.set('score', counter); +}); +``` + +**After:** + +```typescript +const myObject = await channel.object.get(); + +// Batch is available on any PathObject or Instance - operations execute in that object's context +await myObject + .get('shape') + .get('colour') + .batch((ctx) => { + ctx.set('border', 'green'); + ctx.set('fill', 'yellow'); + }); + +// Batch on Instance +const shape = myObject.get('shape').instance(); +if (shape) { + await shape.batch((ctx) => { + ctx.set('name', 'square'); + ctx.set('size', 50); + }); +} + +await myObject.batch((ctx) => { + // Object creation is now supported inside a batch + ctx.set('score', LiveCounter.create(100)); + ctx.set( + 'metadata', + LiveMap.create({ + timestamp: Date.now().toString(), + version: '1.0', + }), + ); +}); +``` + +#### Access explicit object instances using `.instance()` + +If you need to work with a specific `LiveMap` or `LiveCounter` instance (rather than a path), use the `.instance()` method. + +**When to use `.instance()`:** + +In most scenarios, using `PathObject` is recommended as it provides path-based operations that are resilient to object replacements. However, `.instance()` is useful when you need to: + +1. **Subscribe to a specific instance regardless of its location**: Instance subscriptions follow the object even if it moves within the hierarchy or is stored in different map keys. + +2. **Get the underlying object ID for REST API operations**: Each LiveMap and LiveCounter has a unique object ID (accessible via the `.id` property) that can be used with the Objects REST API. + +**Before:** + +```typescript +const root = await channel.objects.getRoot(); +const player = root.get('players').get('player1'); +// player is a LiveMap instance +await player.set('score', 100); +``` + +**After:** + +```typescript +const myObject = await channel.object.get(); + +// Option 1: Use PathObject for path-based operations (recommended for most cases) +await myObject.get('players').get('player1').set('score', 100); + +// Option 2: Get the explicit instance when you need the object ID or instance subscriptions +const player = myObject.get('players').get('player1').instance(); +// player is an Instance | undefined +if (player) { + // Get object ID for REST API operations + const objectId = player.id; // e.g., "map:abc123..." + + // Subscribe to this instance, tracking it wherever it moves + player.subscribe(({ object, message }) => { + // Notified about changes to this specific player instance + // even if it's moved to a different key (e.g., from 'player1' to 'player2') + }); + + await player.set('score', 100); +} +``` + +**Key difference:** `PathObject` methods resolve the object at the path each time they're called. `Instance` methods always operate on the same specific object instance. + +**Understand `Instance` runtime type checking:** + +The `Instance` class behaves similarly to `PathObject` in terms of error handling, but operates on a specific object instance: + +- **`.instance()` returns `undefined`** if no object exists at the path: + + ```typescript + const player = myObject.get('nonexistent').instance(); + // player is undefined + ``` + +- **Access methods return empty defaults** when called on the wrong instance type: + + ```typescript + // Assume 'visits' is a LiveCounter, not a LiveMap + const visits = myObject.get('visits').instance(); // Returns Instance + + // Calling LiveMap-specific methods returns empty defaults + for (const [key, value] of visits.entries()) { + // Empty iterator - loop body never executes + } + + const size = visits.size(); // Returns undefined + ``` + +- **Mutation methods throw errors** when called on the wrong instance type: + + ```typescript + // Assume 'metadata' is a LiveMap, not a LiveCounter + const metadata = myObject.get('metadata').instance(); // Returns LiveMap instance + + // Calling LiveCounter-specific mutation throws an error + await metadata.increment(1); + // Throws: operation error - Cannot increment a non-LiveCounter instance + ``` + +**Note:** The old API returned explicit `LiveMap` and `LiveCounter` instances directly. The new `Instance` class wraps these and provides the unified error handling behavior described above. + +### Only TypeScript users + +#### Stop using global `AblyObjectsTypes` interface + +The global `AblyObjectsTypes` interface has been removed. You should now provide a type parameter that describes your object on a channel explicitly when calling `channel.object.get()`. + +**Before:** + +```typescript +import { LiveCounter, LiveMap } from 'ably'; + +declare global { + interface AblyObjectsTypes { + root: { + players: LiveMap<{ name: string; score: LiveCounter }>; + status: string; + }; + } +} + +const root = await channel.objects.getRoot(); // Automatically typed +``` + +**After:** + +```typescript +import { LiveCounter, LiveMap } from 'ably'; + +type GameState = { + players: LiveMap<{ name: string; score: LiveCounter }>; + status: string; +}; + +const myObject = await channel.object.get(); +// myObject is now PathObject> +``` + +The new PathObject API makes extensive use of TypeScript generics to provide type safety at compilation time. You can specify the expected shape of your objects and expect all PathObject and Instance API methods to correctly resolve the underlying type hierarchy: + +```typescript +type UserProfile = { + name: string; + age: number; + settings: LiveMap<{ + theme: string; + notifications: boolean; + }>; + loginCount: LiveCounter; +}; + +const myObject = await channel.object.get(); + +// TypeScript knows the structure +const name: string = myObject.get('name').value(); +const settings = myObject.get('settings'); // PathObject> +const theme: string = settings.get('theme').value(); +const loginCount = myObject.get('loginCount'); // PathObject +const settingsCompact = settings.compact(); // { theme: string; notifications: boolean } +``` + +#### Update imports for renamed types + +The following types have been renamed for clarity and consistency: + +- `Objects` → `RealtimeObject` +- `OnObjectsEventResponse` → `StatusSubscription` +- `PrimitiveObjectValue` → `Primitive` +- `SubscribeResponse` → `Subscription` + +#### Stop referring to removed types + +The following types have been removed: + +- `DefaultRoot` +- `LiveMapType` +- `LiveObjectUpdateCallback` - replaced by `EventCallback` in the subscription API +- `LiveMapUpdate`, `LiveCounterUpdate`, `LiveObjectUpdate` - replaced by `PathObjectSubscriptionEvent` and `InstanceSubscriptionEvent` for `PathObject` and `Instance` subscription callbacks +- `LiveObjectLifecycleEvents` namespace and `LiveObjectLifecycleEvent` type - removed along with [LiveObject lifecycle events](#stop-using-lifecycle-event-subscriptions-on-liveobject) +- `LiveObjectLifecycleEventCallback` and `OnLiveObjectLifecycleEventResponse` +- `BatchCallback` - replaced by `BatchFunction` in the batch API +- `BatchContextLiveMap` and `BatchContextLiveCounter` + +#### Be aware of changes to LiveMap, LiveCounter, and LiveObject interfaces + +The `LiveMap` and `LiveCounter` interfaces have been redesigned as empty branded interfaces used solely for type identification. They no longer provide concrete methods. The actual API surface for objects is now available through the `PathObject` and `Instance` types. + +Additionally, `LiveObject` is now a union type: `LiveObject = LiveMap | LiveCounter`, and the `LiveMap` type parameter has changed from `LiveMap` to `LiveMap>`. + +To access the API methods, use `PathObject` or `Instance` types instead of working with the interfaces directly (for example, `PathObject>` or `Instance`). + +#### Be aware of changes to the BatchContext interface + +The `BatchContext` interface has been redesigned as a generic type `BatchContext` that operates on a specific object instance within a `BatchFunction`. + +Key changes: + +- The `getRoot()` method has been removed +- The context provides API methods corresponding to the underlying instance type (e.g., `BatchContext>` provides LiveMap operations, `BatchContext` provides LiveCounter operations) + +### Common migration patterns + +#### Reading values + +**Before:** + +```typescript +const root = await channel.objects.getRoot(); +const username = root.get('user').get('name'); // 'Alice' +const visits = root.get('visits').value(); // 42 +``` + +**After:** + +```typescript +const myObject = await channel.object.get(); +const username = myObject.get('user').get('name').value(); // 'Alice' +const visits = myObject.get('visits').value(); // 42 +``` + +#### Updating values + +**Before:** + +```typescript +const root = await channel.objects.getRoot(); +await root.get('user').set('name', 'Bob'); +await root.get('visits').increment(1); +``` + +**After:** + +```typescript +const myObject = await channel.object.get(); +await myObject.get('user').set('name', 'Bob'); +await myObject.get('visits').increment(1); +``` + +#### Observing changes to a specific location + +**Before:** + +```typescript +const root = await channel.objects.getRoot(); +let subscription = root.get('currentUser').subscribe(onUserUpdate); + +// If currentUser is replaced, need to re-subscribe +root.subscribe((update) => { + if (update.currentUser === 'updated') { + subscription.unsubscribe(); + subscription = root.get('currentUser').subscribe(onUserUpdate); + } +}); +``` + +**After:** + +```typescript +const myObject = await channel.object.get(); + +// PathObject subscription is resilient to instance changes +myObject.get('currentUser').subscribe(({ object, message }) => { + // Always observes whatever is at the 'currentUser' path + onUserUpdate(object, message); +}); +``` + +#### Working with a specific object instance + +**Before:** + +```typescript +const root = await channel.objects.getRoot(); +const leaderboard = root.get('leaderboard'); +const player = leaderboard.get(0); + +// Subscribe to a specific player instance +player.subscribe((update) => { + // Follows this player even if they move in the leaderboard +}); +``` + +**After:** + +```typescript +const myObject = await channel.object.get(); +const player = myObject.get('leaderboard').get(0).instance(); + +if (player) { + player.subscribe(({ object, message }) => { + // Follows this specific player instance + }); +} +``` + +## Take advantage of new Objects features that v2.16 introduces + +### Implicit channel attach on `object.get()` call + +Previously, you needed to explicitly call `await channel.attach()` before accessing objects. The `channel.object.get()` method now performs an implicit attach, preventing a common issue where forgetting to attach would cause `channel.objects.getRoot()` call to hang indefinitely. + +**Before:** + +```typescript +await channel.attach(); // Explicit attach required - forgetting this would cause hangs +const root = await channel.objects.getRoot(); +``` + +**After:** + +```typescript +// No explicit attach needed - .get() handles it automatically +const myObject = await channel.object.get(); +// The channel is automatically attached and synced +``` + +### Object compact representation with `.compact()` and `.compactJson()` + +Two methods are available for converting LiveObjects to plain JavaScript objects: + +- `.compact()` - returns an in-memory JavaScript object representation, presenting binary data as buffers (`Buffer` in Node.js, `ArrayBuffer` elsewhere) and using direct object references for cyclic structures +- `.compactJson()` - returns a JSON-serializable representation, encoding binary data as base64 strings and representing cyclic references as `{ objectId: string }` + +#### Use `.compact()` to get in-memory object representation + +```typescript +const myObject = await channel.object.get(); +await myObject.set( + 'gameState', + LiveMap.create({ + playerName: 'Alice', + score: LiveCounter.create(100), + avatar: new ArrayBuffer(8), // Binary data + settings: LiveMap.create({ + theme: 'dark', + volume: 80, + }), + }), +); + +const compactRepresentation = myObject.get('gameState').compact(); +// Returns: +// { +// playerName: "Alice", +// score: 100, // LiveCounter compacted to number +// avatar: ArrayBuffer(8), +// settings: { +// theme: "dark", +// volume: 80 +// } +// } + +// Also works on instances +const gameState = myObject.get('gameState').instance(); +if (gameState) { + const compact = gameState.compact(); // Same result +} + +// Individual counter compact +const score = myObject.get('gameState').get('score').compact(); // Returns: 100 +``` + +#### Use `.compactJson()` to get JSON-serializable representation + +Use `.compactJson()` when you need a JSON-serializable representation: + +```typescript +const compactJson = myObject.get('gameState').compactJson(); +// Returns: +// { +// "playerName": "Alice", +// "score": 100, +// "avatar": "AAAAAAAAAAA=", // binary data encoded as base64 string +// "settings": { +// "theme": "dark", +// "volume": 80 +// } +// } + +// Safe to serialize +const jsonString = JSON.stringify(compactJson); +``` + +### Async iterator API for subscriptions + +You can now use async iterators with subscriptions, providing a modern way to handle updates. + +```typescript +const myObject = await channel.object.get(); + +// Use for await...of to iterate over updates +for await (const { object, message } of myObject.subscribeIterator()) { + console.log('Change at path:', object.path()); + console.log('Operation:', message.operation); + + // Break based on some condition + if (shouldStop) { + break; // This will automatically unsubscribe + } +} +``` + +With depth control: + +```typescript +// Only observe object-level changes +for await (const { object, message } of myObject.subscribeIterator({ depth: 1 })) { + console.log('Object-level change:', object.path()); +} +``` From 108a66dacc39562bdc22aab614ecc526e85aac14 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Wed, 17 Dec 2025 17:18:24 +0000 Subject: [PATCH 43/45] Phrasing change based on https://github.com/ably/docs/pull/3019#discussion_r2619377342 --- docs/migration-guides/v2/liveobjects.md | 2 +- liveobjects.d.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/migration-guides/v2/liveobjects.md b/docs/migration-guides/v2/liveobjects.md index 86b167af63..6f047db215 100644 --- a/docs/migration-guides/v2/liveobjects.md +++ b/docs/migration-guides/v2/liveobjects.md @@ -250,7 +250,7 @@ shape.subscribe((update) => { const myObject = await channel.object.get(); myObject.get('visits').subscribe(({ object, message }) => { - // object: PathObject representing the updated path + // object: PathObject representing the path at which there was an object change // message: ObjectMessage that carried the operation that led to the change, if applicable console.log('Updated path:', object.path()); console.log('Operation:', message.operation); diff --git a/liveobjects.d.ts b/liveobjects.d.ts index 5280b85791..d3472f5562 100644 --- a/liveobjects.d.ts +++ b/liveobjects.d.ts @@ -255,7 +255,7 @@ interface PathObjectBase { /** * Registers a listener that is called each time the object or a primitive value at this path is updated. * - * The provided listener receives a {@link PathObject} representing the updated path, + * The provided listener receives a {@link PathObject} representing the path at which there was an object change, * and, if applicable, an {@link ObjectMessage} that carried the operation that led to the change. * * By default, subscriptions observe nested changes, but you can configure the observation depth @@ -1436,7 +1436,7 @@ export type Instance = [T] extends [LiveMap] * The event object passed to a {@link PathObject} subscription listener. */ export type PathObjectSubscriptionEvent = { - /** The {@link PathObject} representing the updated path. */ + /** The {@link PathObject} representing the path at which there was an object change. */ object: PathObject; /** The {@link ObjectMessage} that carried the operation that led to the change, if applicable. */ message?: ObjectMessage; From 760ddc2156bbb4dd011a614667691a565a78ad86 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Fri, 19 Dec 2025 11:47:40 +0000 Subject: [PATCH 44/45] Add plugin import and `offAll` changes to LiveObjects migration guide - Add section documenting plugin import changes: path renamed from 'ably/objects' to 'ably/liveobjects', default export changed to named export, plugin name changed from Objects to LiveObjects - Add TypeScript section for types moving from 'ably' to 'ably/liveobjects' - Add section for RealtimeObject.offAll() removal with individual listener management alternatives - Update existing code examples to use 'ably/liveobjects' import path - Mention UMD bundle global is now AblyLiveObjectsPlugin --- docs/migration-guides/v2/liveobjects.md | 91 +++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 5 deletions(-) diff --git a/docs/migration-guides/v2/liveobjects.md b/docs/migration-guides/v2/liveobjects.md index 6f047db215..123aec8d98 100644 --- a/docs/migration-guides/v2/liveobjects.md +++ b/docs/migration-guides/v2/liveobjects.md @@ -14,7 +14,7 @@ Here's how to migrate your LiveObjects usage to the new PathObject-based API int 1. [Understand `PathObject`](#understand-pathobject). 2. [Update to v2.16 or later and handle breaking changes](#update-to-v216-or-later-and-handle-breaking-changes). -3. (Optional) [Take advantage of new Objects features that v2.16 introduces](#take-advantage-of-new-objects-features-that-v216-introduces). +3. (Optional) [Take advantage of new LiveObjects features that v2.16 introduces](#take-advantage-of-new-liveobjects-features-that-v216-introduces). 4. (Optional) Check out [common migration patterns](#common-migration-patterns) for a quick reference. ## Understand `PathObject` @@ -44,6 +44,40 @@ The changes below are split into: ### General changes +#### Update LiveObjects plugin import + +The LiveObjects plugin import has changed in several ways: + +1. The import path has changed from `'ably/objects'` to `'ably/liveobjects'` +2. The plugin is now a named export instead of a default export +3. The plugin name has changed from `Objects` to `LiveObjects`, which also affects the key used in the `plugins` client option + +**Before:** + +```typescript +import * as Ably from 'ably'; +import Objects from 'ably/objects'; + +const client = new Ably.Realtime({ + key: 'your-api-key', + plugins: { Objects }, +}); +``` + +**After:** + +```typescript +import * as Ably from 'ably'; +import { LiveObjects } from 'ably/liveobjects'; + +const client = new Ably.Realtime({ + key: 'your-api-key', + plugins: { LiveObjects }, +}); +``` + +**Note:** If you're using the UMD bundle via a `