From 1244065c40fac6c23333277545ebfaa00317a1c3 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Fri, 7 Nov 2025 08:22:01 +0000 Subject: [PATCH] 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 | 485 +++++++++++++----- scripts/moduleReport.ts | 3 +- src/plugins/objects/batchcontext.ts | 161 +++--- .../objects/batchcontextlivecounter.ts | 41 -- src/plugins/objects/batchcontextlivemap.ts | 62 --- src/plugins/objects/instance.ts | 35 +- src/plugins/objects/livemap.ts | 68 +-- src/plugins/objects/pathobject.ts | 40 +- src/plugins/objects/realtimeobject.ts | 20 - src/plugins/objects/rootbatchcontext.ts | 78 +++ test/realtime/objects.test.js | 354 +++++++------ 11 files changed, 787 insertions(+), 560 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 dbd7b0abe..749fee793 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,307 @@ 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}. + */ +export 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 LiveMapOperations = Record> { +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; + + // Override set method to preserve proper generic constraints that are lost after BatchContextOperations conditional type mapping. + // See: https://stackoverflow.com/questions/69855855/can-i-preserve-generics-in-conditional-types + set(key: K, value: T[K]): void; +} + +/** + * 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 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 +3064,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 +3121,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 +3168,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 +3191,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 +3456,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 +3772,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 db927ee17..af5444d6a 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 358e8ff78..a63f9ba8e 100644 --- a/src/plugins/objects/batchcontext.ts +++ b/src/plugins/objects/batchcontext.ts @@ -1,107 +1,118 @@ 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 interface InstanceEvent { + /** Object message that caused this event */ + message?: ObjectMessage; +} + +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 { - this._realtimeObject.throwIfInvalidAccessApiConfiguration(); - this.throwIfClosed(); - return this.getWrappedObject(ROOT_OBJECT_ID) as BatchContextLiveMap; + get(key: string): BatchContext | undefined { + 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._throwIfClosed(); + return this._instance.value(); + } - const originObject = this._realtimeObject.getPool().get(objectId); - if (!originObject) { - return undefined; + compact(): CompactedValue | undefined { + this._throwIfClosed(); + return this._instance.compact(); + } + + id(): string | undefined { + this._throwIfClosed(); + return this._instance.id(); + } + + *entries>(): IterableIterator<[keyof T, BatchContext]> { + this._throwIfClosed(); + for (const [key, value] of this._instance.entries()) { + const ctx = this._rootContext.wrapInstance(value) as unknown as BatchContext; + yield [key, ctx]; } + } - 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, - ); + *keys>(): IterableIterator { + this._throwIfClosed(); + yield* this._instance.keys(); + } + + *values>(): IterableIterator> { + this._throwIfClosed(); + for (const [_, value] of this.entries()) { + yield value; } + } - this._wrappedObjects.set(objectId, wrappedObject); - return wrappedObject; + size(): number | undefined { + this._throwIfClosed(); + return this._instance.size(); } - /** - * @internal - */ - throwIfClosed(): void { - if (this.isClosed()) { - throw new this._client.ErrorInfo('Batch is closed', 40000, 400); + set(key: string, value: Value): void { + 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 - */ - isClosed(): boolean { - return this._isClosed; + remove(key: string): void { + 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 - */ - close(): void { - this._isClosed = true; + increment(amount?: number): void { + 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), + ]); } - /** - * @internal - */ - queueMessage(msg: ObjectMessage): void { - this._queuedMessages.push(msg); + decrement(amount?: number): void { + 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)); } - /** - * @internal - */ - async flush(): Promise { - try { - this.close(); - - 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 fc1a9f9eb..000000000 --- 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 6edacedde..000000000 --- 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 da9e35f1d..d47c53db7 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,28 @@ export class DefaultInstance implements AnyInstance { return unsubscribe; }); } + + async batch(fn: BatchFunction): Promise { + 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 012aaf53f..91215a6d8 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 011409832..00e0b6dbb 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. @@ -231,8 +235,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 +371,25 @@ export class DefaultPathObject implements AnyPathObject { }); } + async batch(fn: BatchFunction): Promise { + const instance = this.instance(); + 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; diff --git a/src/plugins/objects/realtimeobject.ts b/src/plugins/objects/realtimeobject.ts index bada9845c..e5a2b1633 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 000000000..9a34262c4 --- /dev/null +++ b/src/plugins/objects/rootbatchcontext.ts @@ -0,0 +1,78 @@ +import type { Instance, Value } from '../../../ably'; +import { DefaultBatchContext } from './batchcontext'; +import { ObjectMessage } from './objectmessage'; +import { RealtimeObject } from './realtimeobject'; + +export interface InstanceEvent { + /** Object message that caused this event */ + message?: ObjectMessage; +} + +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 (this._queuedMessageConstructors.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 fc5031700..8b8af198c 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(ctxInnerCounter, 'Check inner counter object can be accessed from a map in a batch API').to.exist; + 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(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', }); }, @@ -4325,6 +4297,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 +4348,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 +4442,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 +5277,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(async (ctx) => { + expect(ctx, 'Check batch context exists').to.exist; + expectInstanceOf(ctx, 'RootBatchContext', 'Check batch context is of RootBatchContext type'); + }); + }, + }, ]; const instanceScenarios = [ @@ -5691,12 +5686,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 +5700,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 +6276,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(async (ctx) => { + expect(ctx, 'Check batch context exists').to.exist; + expectInstanceOf(ctx, 'RootBatchContext', 'Check batch context is of RootBatchContext type'); + }); + }, + }, ]; /** @nospec */ @@ -7169,25 +7179,23 @@ 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 = [ @@ -7203,8 +7211,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function // 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, map, counter, errorMsg: '"object_subscribe" channel mode' }); + expectBatchContextWriteApiToThrow({ ctx, map, counter, errorMsg: '"object_publish" channel mode' }); }); await expectAccessApiToThrow({ realtimeObject, map, counter, errorMsg: '"object_subscribe" channel mode' }); @@ -7227,8 +7235,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function 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, map, counter, errorMsg: '"object_subscribe" channel mode' }); + expectBatchContextWriteApiToThrow({ ctx, map, counter, errorMsg: '"object_publish" channel mode' }); }); await expectAccessApiToThrow({ realtimeObject, map, counter, errorMsg: '"object_subscribe" channel mode' }); @@ -7249,8 +7257,13 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function 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, + map, + counter, + errorMsg: 'failed as channel state is detached', + }); + expectBatchContextWriteApiToThrow({ ctx, map, counter, errorMsg: 'failed as channel state is detached' }); }); await expectAccessApiToThrow({ @@ -7281,8 +7294,8 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function 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, map, counter, errorMsg: 'failed as channel state is failed' }); + expectBatchContextWriteApiToThrow({ ctx, map, counter, errorMsg: 'failed as channel state is failed' }); }); await expectAccessApiToThrow({ @@ -7313,7 +7326,12 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function helper.recordPrivateApi('call.channel.requestState'); channel.requestState('suspended'); - expectWriteBatchApiToThrow({ ctx, map, counter, errorMsg: 'failed as channel state is suspended' }); + expectBatchContextWriteApiToThrow({ + ctx, + map, + counter, + errorMsg: 'failed as channel state is suspended', + }); }); await expectWriteApiToThrow({ @@ -7338,7 +7356,7 @@ define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function helper.recordPrivateApi('write.realtime.options.echoMessages'); client.options.echoMessages = false; - expectWriteBatchApiToThrow({ ctx, map, counter, errorMsg: '"echoMessages" client option' }); + expectBatchContextWriteApiToThrow({ ctx, map, counter, errorMsg: '"echoMessages" client option' }); }); await expectWriteApiToThrow({ realtimeObject, map, counter, errorMsg: '"echoMessages" client option' });