diff --git a/.gitignore b/.gitignore index 1d41eea395..264fe143c1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ ably-js.iml node_modules npm-debug.log .tool-versions +liveobjects.d.mts build/ react/ typedoc/generated/ diff --git a/Gruntfile.js b/Gruntfile.js index 7ba8985289..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,13 +138,14 @@ module.exports = function (grunt) { }); }); - grunt.registerTask('build:objects', function () { + grunt.registerTask('build:liveobjects:bundle', function () { var done = this.async(); Promise.all([ - esbuild.build(esbuildConfig.objectsPluginConfig), - 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); @@ -154,10 +155,23 @@ module.exports = function (grunt) { }); }); + grunt.registerTask( + 'build:liveobjects:types', + 'Generate liveobjects.d.mts from liveobjects.d.ts by adding .js extensions to relative imports', + function () { + const dtsContent = fs.readFileSync('liveobjects.d.ts', 'utf8'); + const mtsContent = dtsContent.replace(/from '(\.\/[^']+)'/g, "from '$1.js'"); + fs.writeFileSync('liveobjects.d.mts', mtsContent); + grunt.log.ok('Generated liveobjects.d.mts from liveobjects.d.ts'); + }, + ); + + 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 a324ba75b9..bd596e4a0d 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/liveobjects' + * (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}. */ @@ -626,9 +636,9 @@ 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 `RealtimeChannel.object`. */ - Objects?: unknown; + LiveObjects?: unknown; } /** @@ -1646,30 +1656,55 @@ 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 which returns only a single argument - an event 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 Objects}. + * @param event - The event which triggered the callback. */ -export type ObjectsEventCallback = () => void; +export type EventCallback = (event: T) => void; /** - * The callback used for the lifecycle events emitted by {@link LiveObject}. + * 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 type LiveObjectLifecycleEventCallback = () => void; +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; +} /** - * A function passed to {@link Objects.batch} to group multiple Objects operations into a single channel message. - * - * Must not be `async`. + * 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. * - * @param batchContext - A {@link BatchContext} object that allows grouping Objects operations for this batch. + * @example + * ```typescript + * const s = someService.on(); + * // Later when done with the subscription + * s.off(); + * ``` */ -export type BatchCallback = (batchContext: BatchContext) => void; +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 @@ -2249,523 +2284,6 @@ export declare interface PushChannel { listSubscriptions(params?: Record): Promise>; } -/** - * 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 Objects} object. - */ -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 LiveObject} object. - */ -export type LiveObjectLifecycleEvent = LiveObjectLifecycleEvents.DELETED; - -/** - * Enables the Objects to be read, modified and subscribed to for a channel. - */ -export declare interface Objects { - /** - * Retrieves the root {@link LiveMap} object 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}. - * - * Example: - * - * ```typescript - * import { LiveCounter } from 'ably'; - * - * type MyRoot = { - * myTypedKey: LiveCounter; - * }; - * - * declare global { - * export interface AblyObjectsTypes { - * root: MyRoot; - * } - * } - * ``` - * - * @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>; - - /** - * Creates a new {@link LiveMap} 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. - * @experimental - */ - createMap(entries?: T): Promise>; - - /** - * Creates a new {@link LiveCounter} 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. - * @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. - * - * 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. - * - * @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. - * @experimental - */ - on(event: ObjectsEvent, callback: ObjectsEventCallback): OnObjectsEventResponse; - - /** - * 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; -} - -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 LiveMap}. - * It maps string keys to primitive values ({@link PrimitiveObjectValue}), or other {@link LiveObject | LiveObjects}. - */ -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. - * - * - 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. - */ -export type DefaultRoot = - // 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`; - -/** - * 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; -} - -/** - * Enables grouping multiple Objects operations together by providing `BatchContext*` wrapper objects. - */ -export declare interface BatchContext { - /** - * Mirrors the {@link Objects.getRoot} method and returns a {@link BatchContextLiveMap} wrapper for the root object on a channel. - * - * @returns A {@link BatchContextLiveMap} object. - * @experimental - */ - getRoot(): BatchContextLiveMap; -} - -/** - * A wrapper around the {@link LiveMap} 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. - * - * @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. - * @experimental - */ - get(key: TKey): T[TKey] | undefined; - - /** - * Returns the number of key-value pairs in the map. - * - * @experimental - */ - 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. - * - * 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. - * - * @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 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. - * - * 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. - * - * @param key - The key to set the value for. - * @experimental - */ - remove(key: TKey): void; -} - -/** - * A wrapper around the {@link LiveCounter} 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 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. - * - * 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. - * - * @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, - * 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}). - */ -export declare interface LiveMap extends LiveObject> { - /** - * 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. - * - * 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. - * @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. - * To get notified when object gets updated, use the {@link LiveObject.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: 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. - * To get notified when object gets updated, use the {@link LiveObject.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: TKey): Promise; -} - -/** - * Represents an update to a {@link LiveMap} 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 LiveMap}. - * - * 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 LiveCounter extends LiveObject { - /** - * 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. - * To get notified when object gets updated, use the {@link LiveObject.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. - * @experimental - */ - increment(amount: number): Promise; - - /** - * An alias for calling {@link LiveCounter.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; -} - -/** - * Represents an update to a {@link LiveCounter} 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 LiveObject { - /** - * 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; - - /** - * 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; -} - -/** - * Represents a generic update object describing the changes that occurred on a LiveObject. - */ -export declare interface LiveObjectUpdate { - /** - * Holds an update object which describe changes applied to the object. - */ - update: any; - /** The client ID of the client that published this update. */ - clientId?: string; - /** The connection ID of the client that published this update. */ - connectionId?: string; -} - -/** - * 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. */ @@ -2983,11 +2501,7 @@ export declare interface RealtimeChannel extends EventEmitter` tag, the global variable name is now `AblyLiveObjectsPlugin` instead of `AblyObjectsPlugin`. + +#### 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/liveobjects'; + +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 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); + 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(); +``` + +#### Replace `offAll()` with individual listener management + +The `offAll()` method has been removed from the `RealtimeObject` status event API. Instead, deregister listeners individually using either the subscription object returned by `.on()`, or by calling `.off(event, callback)` with the callback reference. + +**Before:** + +```typescript +const channelObjects = channel.objects; + +channelObjects.on('synced', () => console.log('Synced 1')); +channelObjects.on('synced', () => console.log('Synced 2')); + +// Unregister all listeners at once +channelObjects.offAll(); +``` + +**After:** + +```typescript +const channelObject = channel.object; + +// Option 1: Use the subscription object returned by .on() +const subscription1 = channelObject.on('synced', () => console.log('Synced 1')); +subscription1.off(); + +// Option 2: Use .off(event, callback) with a callback reference +const onSynced2 = () => console.log('Synced 2'); +channelObject.on('synced', onSynced2); +channelObject.off('synced', onSynced2); +``` + +#### 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 LiveObjects 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 + +#### Update imports for LiveObjects types + +All LiveObjects-related types have been moved from the `'ably'` export to `'ably/liveobjects'`. This consolidates all LiveObjects functionality in one place. + +**Before:** + +```typescript +import { Objects, LiveCounter, LiveMap } from 'ably'; +``` + +**After:** + +```typescript +import { RealtimeObject, LiveCounter, LiveMap } from 'ably/liveobjects'; +``` + +#### 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/liveobjects'; + +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 LiveObjects 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()); +} +``` diff --git a/grunt/esbuild/build.js b/grunt/esbuild/build.js index 2144ce2709..b6d7b95c04 100644 --- a/grunt/esbuild/build.js +++ b/grunt/esbuild/build.js @@ -77,26 +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 objectsPluginCdnConfig = { +const liveObjectsPluginEsmConfig = { ...createBaseConfig(), - entryPoints: ['src/plugins/objects/index.ts'], - plugins: [umdWrapper.default({ libraryName: 'AblyObjectsPlugin', amdNamedModule: false })], - outfile: 'build/objects.umd.js', + format: 'esm', + plugins: [], + entryPoints: ['src/plugins/liveobjects/index.ts'], + outfile: 'build/liveobjects.mjs', + external: ['dequal'], +}; + +const liveObjectsPluginCdnConfig = { + ...createBaseConfig(), + 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, }; @@ -108,7 +117,8 @@ module.exports = { pushPluginConfig, pushPluginCdnConfig, minifiedPushPluginCdnConfig, - objectsPluginConfig, - objectsPluginCdnConfig, - minifiedObjectsPluginCdnConfig, + liveObjectsPluginConfig, + liveObjectsPluginEsmConfig, + liveObjectsPluginCdnConfig, + minifiedLiveObjectsPluginCdnConfig, }; diff --git a/liveobjects.d.ts b/liveobjects.d.ts new file mode 100644 index 0000000000..d3472f5562 --- /dev/null +++ b/liveobjects.d.ts @@ -0,0 +1,1733 @@ +/** + * You are currently viewing the Ably LiveObjects plugin type definitions for the Ably JavaScript Client Library SDK. + * + * 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 + */ + +/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */ +import { + ErrorInfo, + EventCallback, + RealtimeChannel, + RealtimeClient, + StatusSubscription, + Subscription, + __livetype, +} from './ably'; +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]; + +/** + * 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. + * 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. + * + * Example: + * + * ```typescript + * import { LiveCounter } from 'ably/liveobjects'; + * + * 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. + */ + 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. + */ + 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. + */ + off(event: ObjectsEvent, callback: ObjectsEventCallback): 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`. + */ + 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 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 + * 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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`. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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`. + */ + 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. + */ + 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`. + */ + 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`. + */ + 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`. + */ + 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`. + */ + 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`. + */ + 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. + */ + 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. + */ + 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. + */ + 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`. + */ + 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. + */ + 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`. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ +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`. + */ + 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. + */ + 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. + */ + 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. + */ + values(): IterableIterator>; + + /** + * Returns the number of entries in the map. + * + * If the underlying instance at runtime is not a map, returns `undefined`. + */ + 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. + */ + 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. + */ + 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. + */ + 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`. + */ + 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`. + */ + 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`. + */ + 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`. + */ + 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`. + */ + 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`. + */ + 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. + */ + 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. + */ + 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. + */ + values>(): IterableIterator>; + + /** + * Returns the number of entries in the map. + * + * If the underlying instance at runtime is not a map, returns `undefined`. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ +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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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`. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + values(): IterableIterator>; + + /** + * Returns the number of entries in the map. + * + * If the underlying instance at runtime is not a map, returns `undefined`. + */ + 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. + */ + 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. + */ + 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. + */ + 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`. + */ + 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`. + */ + 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`. + */ + 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`. + */ + 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`. + */ + 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`. + */ + 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. + */ + 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. + */ + 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. + */ + values>(): IterableIterator>; + + /** + * Returns the number of entries in the map. + * + * If the underlying instance at runtime is not a map, returns `undefined`. + */ + 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. + */ + 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`. + */ + 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. + */ + 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. + */ + 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. + */ +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 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; +}; + +/** + * 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 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 LiveMap} value type representing the initial state of the new LiveMap. + */ + 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, + ): LiveMap ? T : {}>; +} + +/** + * Static utilities related to LiveCounter instances. + */ +export class LiveCounter { + /** + * 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 LiveCounter} value type representing the initial state of the new LiveCounter. + */ + static create(initialCount?: number): LiveCounter; +} + +/** + * 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 { LiveObjects } from 'ably/liveobjects'; + * const realtime = new Realtime({ ...options, plugins: { LiveObjects } }); + * ``` + * + * The LiveObjects plugin can also be used with a {@link BaseRealtime} client: + * + * ```javascript + * import { BaseRealtime, WebSocketTransport, FetchRequest } from 'ably/modular'; + * 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 { LiveObjects, LiveCounter, LiveMap } from 'ably/liveobjects'; + * ``` + */ +export declare const LiveObjects: any; + +/** + * Module augmentation to add the `object` property to `RealtimeChannel` when + * importing from 'ably/liveobjects'. This ensures all LiveObjects 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/objects.d.ts b/objects.d.ts deleted file mode 100644 index 6c89bfc3c0..0000000000 --- a/objects.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -// 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 { BaseRealtime } from './modular'; -/* eslint-enable no-unused-vars, @typescript-eslint/no-unused-vars */ - -/** - * 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'; - * const realtime = new Realtime({ ...options, plugins: { Objects } }); - * ``` - * - * 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'; - * const realtime = new BaseRealtime({ ...options, plugins: { WebSocketTransport, FetchRequest, Objects } }); - * ``` - */ -declare const Objects: any; - -export = Objects; diff --git a/package.json b/package.json index b53f02a364..513df5bfa3 100644 --- a/package.json +++ b/package.json @@ -30,15 +30,22 @@ "types": "./push.d.ts", "import": "./build/push.js" }, - "./objects": { - "types": "./objects.d.ts", - "import": "./build/objects.js" + "./liveobjects": { + "import": { + "types": "./liveobjects.d.mts", + "default": "./build/liveobjects.mjs" + }, + "require": { + "types": "./liveobjects.d.ts", + "default": "./build/liveobjects.js" + } } }, "files": [ "build/**", "ably.d.ts", - "objects.d.ts", + "liveobjects.d.ts", + "liveobjects.d.mts", "modular.d.ts", "push.d.ts", "resources/**", @@ -144,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", @@ -159,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 3992ab5121..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']; @@ -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,24 +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/batchcontextlivecounter.ts', - 'src/plugins/objects/batchcontextlivemap.ts', - 'src/plugins/objects/index.ts', - 'src/plugins/objects/livecounter.ts', - 'src/plugins/objects/livemap.ts', - 'src/plugins/objects/liveobject.ts', - 'src/plugins/objects/objectid.ts', - 'src/plugins/objects/objectmessage.ts', - 'src/plugins/objects/objects.ts', - 'src/plugins/objects/objectspool.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); @@ -395,7 +399,7 @@ async function checkBundleFiles(bundleInfo: BundleInfo, allowedFiles: Set ({ tableRows: [...accum.tableRows, ...current.tableRows], @@ -404,7 +408,7 @@ async function checkBundleFiles(bundleInfo: BundleInfo, allowedFiles: Set 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 b1bac95c4a..74792cb4f0 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -17,7 +17,7 @@ import { normaliseChannelOptions } from '../util/defaults'; import { PaginatedResult } from './paginatedresource'; import type { PushChannel } from 'plugins/push'; import type { WirePresenceMessage } from '../types/presencemessage'; -import type { Objects, WireObjectMessage } from 'plugins/objects'; +import type { RealtimeObject, WireObjectMessage } from 'plugins/liveobjects'; import type RealtimePresence from './realtimepresence'; import type RealtimeAnnotations from './realtimeannotations'; @@ -96,7 +96,7 @@ class RealtimeChannel extends EventEmitter { retryTimer?: number | NodeJS.Timeout | null; retryCount: number = 0; _push?: PushChannel; - _objects?: Objects; + _object?: RealtimeObject; constructor(client: BaseRealtime, name: string, options?: API.ChannelOptions) { super(client.logger); @@ -137,8 +137,8 @@ class RealtimeChannel extends EventEmitter { this._push = new client.options.plugins.Push.PushChannel(this); } - if (client.options.plugins?.Objects) { - this._objects = new client.options.plugins.Objects.Objects(this); + if (client.options.plugins?.LiveObjects) { + this._object = new client.options.plugins.LiveObjects.RealtimeObject(this); } } @@ -150,11 +150,11 @@ class RealtimeChannel extends EventEmitter { } /** @spec RTL27 */ - get objects() { - if (!this._objects) { - Utils.throwMissingPluginError('Objects'); // RTL27b + get object() { + if (!this._object) { + Utils.throwMissingPluginError('LiveObjects'); // RTL27b } - return this._objects; // RTL27a + return this._object; // RTL27a } invalidStateError(): ErrorInfo { @@ -550,8 +550,8 @@ class RealtimeChannel extends EventEmitter { this._presence.onAttached(hasPresence); } // the Objects tree needs to be re-synced - if (this._objects) { - this._objects.onAttached(hasObjects); + if (this._object) { + this._object.onAttached(hasObjects); } } const change = new ChannelStateChange(this.state, this.state, resumed, hasBacklog, message.error); @@ -618,7 +618,7 @@ class RealtimeChannel extends EventEmitter { // OBJECT and OBJECT_SYNC message processing share most of the logic, so group them together case actions.OBJECT: case actions.OBJECT_SYNC: { - if (!this._objects || !message.state) { + if (!this._object || !message.state) { return; } @@ -631,9 +631,9 @@ class RealtimeChannel extends EventEmitter { const objectMessages = message.state.map((om) => 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(); @@ -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> { diff --git a/src/common/lib/transport/comettransport.ts b/src/common/lib/transport/comettransport.ts index 104a521f3c..0dd0f92f5e 100644 --- a/src/common/lib/transport/comettransport.ts +++ b/src/common/lib/transport/comettransport.ts @@ -357,7 +357,7 @@ abstract class CometTransport extends Transport { items[i], this.connectionManager.realtime._RealtimePresence, this.connectionManager.realtime._Annotations, - this.connectionManager.realtime._objectsPlugin, + this.connectionManager.realtime._liveObjectsPlugin, ), ); } catch (e) { diff --git a/src/common/lib/transport/connectionmanager.ts b/src/common/lib/transport/connectionmanager.ts index bde4745b5d..e2d00a2f41 100644 --- a/src/common/lib/transport/connectionmanager.ts +++ b/src/common/lib/transport/connectionmanager.ts @@ -1812,7 +1812,7 @@ class ConnectionManager extends EventEmitter { msg, this.realtime._RealtimePresence, this.realtime._Annotations, - this.realtime._objectsPlugin, + this.realtime._liveObjectsPlugin, ), ); } diff --git a/src/common/lib/transport/protocol.ts b/src/common/lib/transport/protocol.ts index daab68d968..e3f1590e1f 100644 --- a/src/common/lib/transport/protocol.ts +++ b/src/common/lib/transport/protocol.ts @@ -84,7 +84,7 @@ class Protocol extends EventEmitter { pendingMessage.message, this.transport.connectionManager.realtime._RealtimePresence, this.transport.connectionManager.realtime._Annotations, - this.transport.connectionManager.realtime._objectsPlugin, + this.transport.connectionManager.realtime._liveObjectsPlugin, ), ); } diff --git a/src/common/lib/transport/transport.ts b/src/common/lib/transport/transport.ts index 8d4dce3781..28024bcb07 100644 --- a/src/common/lib/transport/transport.ts +++ b/src/common/lib/transport/transport.ts @@ -132,7 +132,7 @@ abstract class Transport extends EventEmitter { message, this.connectionManager.realtime._RealtimePresence, this.connectionManager.realtime._Annotations, - this.connectionManager.realtime._objectsPlugin, + this.connectionManager.realtime._liveObjectsPlugin, ) + '; connectionId = ' + this.connectionManager.connectionId, diff --git a/src/common/lib/transport/websockettransport.ts b/src/common/lib/transport/websockettransport.ts index 3a2aa1c92a..6467a8a4b7 100644 --- a/src/common/lib/transport/websockettransport.ts +++ b/src/common/lib/transport/websockettransport.ts @@ -141,7 +141,7 @@ class WebSocketTransport extends Transport { this.connectionManager.realtime._MsgPack, this.connectionManager.realtime._RealtimePresence, this.connectionManager.realtime._Annotations, - this.connectionManager.realtime._objectsPlugin, + this.connectionManager.realtime._liveObjectsPlugin, this.format, ), ); diff --git a/src/common/lib/types/protocolmessage.ts b/src/common/lib/types/protocolmessage.ts index 3f674e3838..031100c77f 100644 --- a/src/common/lib/types/protocolmessage.ts +++ b/src/common/lib/types/protocolmessage.ts @@ -11,7 +11,7 @@ import RealtimeAnnotations from '../client/realtimeannotations'; import RestAnnotations from '../client/restannotations'; import { flags, flagNames, channelModes, ActionName } from './protocolmessagecommon'; import type { Properties } from '../util/utils'; -import type * as ObjectsPlugin from 'plugins/objects'; +import type * as LiveObjectsPlugin from 'plugins/liveobjects'; import { MessageEncoding } from './basemessage'; export const serialize = Utils.encodeBody; @@ -31,7 +31,7 @@ export function deserialize( MsgPack: MsgPack | null, presenceMessagePlugin: PresenceMessagePlugin | null, annotationsPlugin: AnnotationsPlugin | null, - objectsPlugin: typeof ObjectsPlugin | null, + objectsPlugin: typeof LiveObjectsPlugin | null, format?: Utils.Format, ): ProtocolMessage { const deserialized = Utils.decodeBody>(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/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/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/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/liveobjects/batchcontext.ts b/src/plugins/liveobjects/batchcontext.ts new file mode 100644 index 0000000000..d96646ca87 --- /dev/null +++ b/src/plugins/liveobjects/batchcontext.ts @@ -0,0 +1,137 @@ +import type BaseClient from 'common/lib/client/baseclient'; +import type { + AnyBatchContext, + BatchContext, + CompactedJsonValue, + CompactedValue, + Instance, + Primitive, + Value, +} from '../../../liveobjects'; +import { DefaultInstance } from './instance'; +import { LiveCounter } from './livecounter'; +import { LiveMap } from './livemap'; +import { RealtimeObject } from './realtimeobject'; +import { RootBatchContext } from './rootbatchcontext'; + +export class DefaultBatchContext implements AnyBatchContext { + protected _client: BaseClient; + + constructor( + protected _realtimeObject: RealtimeObject, + protected _instance: Instance, + protected _rootContext: RootBatchContext, + ) { + 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(); + const instance = this._instance.get(key); + if (!instance) { + return undefined; + } + return this._rootContext.wrapInstance(instance) as unknown as BatchContext; + } + + value(): T | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + this._throwIfClosed(); + return this._instance.value(); + } + + compact(): CompactedValue | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + this._throwIfClosed(); + return this._instance.compact(); + } + + compactJson(): CompactedJsonValue | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + this._throwIfClosed(); + return this._instance.compactJson(); + } + + *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]; + } + } + + *keys>(): IterableIterator { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + this._throwIfClosed(); + yield* this._instance.keys(); + } + + *values>(): IterableIterator> { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + this._throwIfClosed(); + for (const [_, value] of this.entries()) { + yield value; + } + } + + size(): number | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + this._throwIfClosed(); + return this._instance.size(); + } + + 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), + ); + } + + 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), + ]); + } + + 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)); + } + + private _throwIfClosed(): void { + if (this._rootContext.isClosed()) { + throw new this._client.ErrorInfo('Batch is closed', 40000, 400); + } + } +} diff --git a/src/plugins/liveobjects/constants.ts b/src/plugins/liveobjects/constants.ts new file mode 100644 index 0000000000..a62a2ca70d --- /dev/null +++ b/src/plugins/liveobjects/constants.ts @@ -0,0 +1 @@ +export const ROOT_OBJECT_ID = 'root'; 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/liveobjects/index.ts b/src/plugins/liveobjects/index.ts new file mode 100644 index 0000000000..337a52f442 --- /dev/null +++ b/src/plugins/liveobjects/index.ts @@ -0,0 +1,23 @@ +import { LiveCounterValueType } from './livecountervaluetype'; +import { LiveMapValueType } from './livemapvaluetype'; +import { ObjectMessage, WireObjectMessage } from './objectmessage'; +import { RealtimeObject } from './realtimeobject'; + +export { + LiveCounterValueType as LiveCounter, + LiveMapValueType as LiveMap, + ObjectMessage, + RealtimeObject, + WireObjectMessage, +}; + +/** + * The named LiveObjects plugin object export to be passed to the Ably client. + */ +export const LiveObjects = { + LiveCounter: LiveCounterValueType, + LiveMap: LiveMapValueType, + ObjectMessage, + RealtimeObject, + WireObjectMessage, +}; diff --git a/src/plugins/liveobjects/instance.ts b/src/plugins/liveobjects/instance.ts new file mode 100644 index 0000000000..785ffd2c9c --- /dev/null +++ b/src/plugins/liveobjects/instance.ts @@ -0,0 +1,264 @@ +import type BaseClient from 'common/lib/client/baseclient'; +import type { EventCallback, Subscription } from '../../../ably'; +import type { + AnyInstance, + BatchContext, + BatchFunction, + CompactedJsonValue, + CompactedValue, + Instance, + InstanceSubscriptionEvent, + LiveObject as LiveObjectType, + Primitive, + Value, +} from '../../../liveobjects'; +import { LiveCounter } from './livecounter'; +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 */ + message?: ObjectMessage; +} + +export class DefaultInstance implements AnyInstance { + protected _client: BaseClient; + + constructor( + private _realtimeObject: RealtimeObject, + private _value: T, + ) { + this._client = this._realtimeObject.getClient(); + } + + get id(): string | undefined { + if (!(this._value instanceof LiveObject)) { + // no id exists for non-LiveObject types + return undefined; + } + 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 { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + + 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 { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + + 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 CompactedJsonValue; + } + + return value as CompactedJsonValue; + } + + 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; + } + + 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 { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + + 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 { + 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}; type=${typeof this._value}`, + ); + // unknown type - return undefined + return undefined; + } + } + + *entries>(): IterableIterator<[keyof U, Instance]> { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + + if (!(this._value instanceof LiveMap)) { + // return empty iterator for non-LiveMap objects + return; + } + + for (const [key, value] of this._value.entries()) { + const instance = new DefaultInstance(this._realtimeObject, value) as unknown as Instance; + yield [key, instance]; + } + } + + *keys>(): IterableIterator { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + + if (!(this._value instanceof LiveMap)) { + // return empty iterator for non-LiveMap objects + return; + } + + yield* this._value.keys(); + } + + *values>(): IterableIterator> { + for (const [_, value] of this.entries()) { + yield value; + } + } + + size(): number | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + + if (!(this._value instanceof LiveMap)) { + // can't return size for non-LiveMap objects + return undefined; + } + return this._value.size(); + } + + set = Record>( + 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); + } + return this._value.set(key, value); + } + + 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); + } + return this._value.remove(key); + } + + 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); + } + return this._value.increment(amount ?? 1); + } + + 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); + } + return this._value.decrement(amount ?? 1); + } + + 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, + message: event.message?.toUserFacingMessage(this._realtimeObject.getChannel()), + }); + }); + } + + 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; + }); + } + + 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/livecounter.ts b/src/plugins/liveobjects/livecounter.ts similarity index 72% rename from src/plugins/objects/livecounter.ts rename to src/plugins/liveobjects/livecounter.ts index 4871cb971e..255ec92156 100644 --- a/src/plugins/objects/livecounter.ts +++ b/src/plugins/liveobjects/livecounter.ts @@ -1,14 +1,8 @@ +import { __livetype } from '../../../ably'; +import { LiveCounter as PublicLiveCounter } from '../../../liveobjects'; import { LiveObject, LiveObjectData, LiveObjectUpdate, LiveObjectUpdateNoop } from './liveobject'; -import { ObjectId } from './objectid'; -import { - createInitialValueJSONString, - ObjectData, - ObjectMessage, - ObjectOperation, - ObjectOperationAction, - ObjectsCounterOp, -} from './objectmessage'; -import { Objects } from './objects'; +import { ObjectData, ObjectMessage, ObjectOperation, ObjectOperationAction, ObjectsCounterOp } from './objectmessage'; +import { RealtimeObject } from './realtimeobject'; export interface LiveCounterData extends LiveObjectData { data: number; // RTLC3 @@ -16,18 +10,21 @@ export interface LiveCounterData extends LiveObjectData { export interface LiveCounterUpdate extends LiveObjectUpdate { update: { amount: number }; + _type: 'LiveCounterUpdate'; } /** @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. * * @internal * @spec RTLC4 */ - static zeroValue(objects: Objects, objectId: string): LiveCounter { - return new LiveCounter(objects, objectId); + static zeroValue(realtimeObject: RealtimeObject, objectId: string): LiveCounter { + return new LiveCounter(realtimeObject, objectId); } /** @@ -36,29 +33,17 @@ export class LiveCounter extends LiveObject * * @internal */ - static fromObjectState(objects: Objects, 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; } - /** - * 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(objects: Objects, objectMessage: ObjectMessage): LiveCounter { - const obj = new LiveCounter(objects, objectMessage.operation!.objectId); - obj._mergeInitialDataFromCreateOperation(objectMessage.operation!, objectMessage); - return obj; - } - /** * @internal */ - static createCounterIncMessage(objects: Objects, 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); @@ -79,60 +64,8 @@ export class LiveCounter extends LiveObject return msg; } - /** - * @internal - */ - static async createCounterCreateMessage(objects: Objects, count?: number): Promise { - const client = objects.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._objects.throwIfInvalidAccessApiConfiguration(); // RTLC5a, RTLC5b return this._dataRef.data; // RTLC5c } @@ -146,16 +79,14 @@ 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]); + 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(); // 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)) { @@ -275,24 +206,24 @@ 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); } + + // update will contain the diff between previous value and new value from object state + update = this._updateFromDataDiff(previousDataRef, this._dataRef); + update.objectMessage = 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.clientId = objectMessage.clientId; - update.connectionId = objectMessage.connectionId; return update; } @@ -311,7 +242,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( @@ -327,8 +258,8 @@ export class LiveCounter extends LiveObject return { update: { amount: objectOperation.counter?.count ?? 0 }, - clientId: msg.clientId, - connectionId: msg.connectionId, + objectMessage: msg, + _type: 'LiveCounterUpdate', }; } @@ -362,6 +293,10 @@ 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 }, + objectMessage: msg, + _type: 'LiveCounterUpdate', + }; } } diff --git a/src/plugins/liveobjects/livecountervaluetype.ts b/src/plugins/liveobjects/livecountervaluetype.ts new file mode 100644 index 0000000000..456f3e4fe4 --- /dev/null +++ b/src/plugins/liveobjects/livecountervaluetype.ts @@ -0,0 +1,98 @@ +import { __livetype } from '../../../ably'; +import { LiveCounter } from '../../../liveobjects'; +import { ObjectId } from './objectid'; +import { + createInitialValueJSONString, + ObjectData, + ObjectMessage, + ObjectOperation, + ObjectOperationAction, +} 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 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; + + private constructor(count: number) { + this._count = count; + Object.freeze(this); + } + + 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 + // 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 async createCounterCreateMessage( + realtimeObject: RealtimeObject, + value: LiveCounterValueType, + ): Promise { + 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/liveobjects/livemap.ts similarity index 70% rename from src/plugins/objects/livemap.ts rename to src/plugins/liveobjects/livemap.ts index 1be3c6a9ba..d4d502a251 100644 --- a/src/plugins/objects/livemap.ts +++ b/src/plugins/liveobjects/livemap.ts @@ -1,11 +1,19 @@ import { dequal } from 'dequal'; -import type { Bufferlike } from 'common/platform'; -import type * as API from '../../../ably'; +import { __livetype } from '../../../ably'; +import { + CompactedJsonValue, + CompactedValue, + Primitive, + LiveMap as PublicLiveMap, + LiveObject as PublicLiveObject, + Value, +} from '../../../liveobjects'; +import { LiveCounter } from './livecounter'; +import { LiveCounterValueType } from './livecountervaluetype'; +import { LiveMapValueType } from './livemapvaluetype'; import { LiveObject, LiveObjectData, LiveObjectUpdate, LiveObjectUpdateNoop } from './liveobject'; -import { ObjectId } from './objectid'; import { - createInitialValueJSONString, ObjectData, ObjectMessage, ObjectOperation, @@ -13,9 +21,8 @@ import { ObjectsMapEntry, ObjectsMapOp, ObjectsMapSemantics, - PrimitiveObjectValue, } from './objectmessage'; -import { Objects } from './objects'; +import { RealtimeObject } from './realtimeobject'; export interface ObjectIdObjectData { /** A reference to another object, used to support composable object structures. */ @@ -24,7 +31,7 @@ export interface ObjectIdObjectData { export interface ValueObjectData { /** A decoded leaf value from {@link WireObjectData}. */ - value: string | number | boolean | Bufferlike | API.JsonArray | API.JsonObject; + value: Primitive; } export type LiveMapObjectData = ObjectIdObjectData | ValueObjectData; @@ -40,18 +47,24 @@ 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 PublicLiveMap +{ + declare readonly [__livetype]: 'LiveMap'; // type-only, unique symbol to satisfy branded interfaces, no JS emitted + constructor( - objects: Objects, + realtimeObject: RealtimeObject, private _semantics: ObjectsMapSemantics, objectId: string, ) { - super(objects, objectId); + super(realtimeObject, objectId); } /** @@ -60,8 +73,8 @@ export class LiveMap extends LiveObject(objects: Objects, objectId: string): LiveMap { - return new LiveMap(objects, ObjectsMapSemantics.LWW, objectId); + static zeroValue(realtimeObject: RealtimeObject, objectId: string): LiveMap { + return new LiveMap(realtimeObject, ObjectsMapSemantics.LWW, objectId); } /** @@ -70,47 +83,49 @@ export class LiveMap extends LiveObject(objects: Objects, objectMessage: ObjectMessage): LiveMap { - const obj = new LiveMap(objects, 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; } - /** - * Returns a {@link LiveMap} instance based on the provided MAP_CREATE object operation. - * The provided object operation must hold a valid map object data. - * - * @internal - */ - static fromObjectOperation(objects: Objects, objectMessage: ObjectMessage): LiveMap { - const obj = new LiveMap(objects, objectMessage.operation!.map?.semantics!, objectMessage.operation!.objectId); - obj._mergeInitialDataFromCreateOperation(objectMessage.operation!, objectMessage); - return obj; - } - /** * @internal */ - static createMapSetMessage( - objects: Objects, + static async createMapSetMessage( + realtimeObject: RealtimeObject, objectId: string, - key: TKey, - value: API.LiveMapType[TKey], - ): ObjectMessage { - const client = objects.getClient(); + key: string, + value: Value, + ): Promise { + const client = realtimeObject.getClient(); - LiveMap.validateKeyValue(objects, key, value); + LiveMap.validateKeyValue(realtimeObject, key, value); let objectData: LiveMapObjectData; - if (value instanceof LiveObject) { - const typedObjectData: ObjectIdObjectData = { objectId: value.getObjectId() }; + 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 if (LiveMapValueType.instanceof(value)) { + const { mapCreateMsg, nestedObjectsCreateMsgs } = await LiveMapValueType.createMapCreateMessage( + realtimeObject, + value, + ); + createValueTypesMessages = [...nestedObjectsCreateMsgs, mapCreateMsg]; + + const typedObjectData: ObjectIdObjectData = { objectId: mapCreateMsg.operation?.objectId! }; objectData = typedObjectData; } else { - const typedObjectData: ValueObjectData = { value: value as PrimitiveObjectValue }; + const typedObjectData: ValueObjectData = { value: value as Primitive }; objectData = typedObjectData; } - const msg = ObjectMessage.fromValues( + const mapSetMsg = ObjectMessage.fromValues( { operation: { action: ObjectOperationAction.MAP_SET, @@ -125,18 +140,14 @@ export class LiveMap extends LiveObject( - objects: Objects, - objectId: string, - key: TKey, - ): ObjectMessage { - const client = objects.getClient(); + static createMapRemoveMessage(realtimeObject: RealtimeObject, objectId: string, key: string): ObjectMessage { + const client = realtimeObject.getClient(); if (typeof key !== 'string') { throw new client.ErrorInfo('Map key should be string', 40003, 400); @@ -160,12 +171,8 @@ export class LiveMap extends LiveObject( - objects: Objects, - key: TKey, - value: API.LiveMapType[TKey], - ): void { - const client = objects.getClient(); + static validateKeyValue(realtimeObject: RealtimeObject, key: string, value: Value): void { + const client = realtimeObject.getClient(); if (typeof key !== 'string') { throw new client.ErrorInfo('Map key should be string', 40003, 400); @@ -182,77 +189,6 @@ export class LiveMap extends LiveObject { - const client = objects.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)); - - 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. * @@ -267,22 +203,20 @@ export class LiveMap extends LiveObject(key: TKey): T[TKey] | undefined { - this._objects.throwIfInvalidAccessApiConfiguration(); // RTLM5b, RTLM5c - if (this.isTombstoned()) { - return undefined as T[TKey]; + return undefined; } const element = this._dataRef.data.get(key); // RTLM5d1 if (element === undefined) { - return undefined as T[TKey]; + return undefined; } // RTLM5d2a if (element.tombstone === true) { - return undefined as T[TKey]; + return undefined; } // data always exists for non-tombstoned elements @@ -290,8 +224,6 @@ export class LiveMap extends LiveObject extends LiveObject(): IterableIterator<[TKey, T[TKey]]> { - this._objects.throwIfInvalidAccessApiConfiguration(); - for (const [key, entry] of this._dataRef.data.entries()) { if (this._isMapEntryTombstoned(entry)) { // do not return tombstoned entries @@ -341,10 +271,12 @@ 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]); + async set( + key: TKey, + value: T[TKey] | LiveCounterValueType | LiveMapValueType, + ): Promise { + const msgs = await LiveMap.createMapSetMessage(this._realtimeObject, this.getObjectId(), key, value); + return this._realtimeObject.publish(msgs); } /** @@ -357,9 +289,8 @@ 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]); + const msg = LiveMap.createMapRemoveMessage(this._realtimeObject, this.getObjectId(), key); + return this._realtimeObject.publish([msg]); } /** @@ -498,24 +429,27 @@ export class LiveMap 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); } + + // update will contain the diff between previous value and new value from object state + update = this._updateFromDataDiff(previousDataRef, this._dataRef); + update.objectMessage = 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.clientId = objectMessage.clientId; - update.connectionId = objectMessage.connectionId; + // Update parent references based on the calculated diff + this._updateParentReferencesFromUpdate(update, previousDataRef); + return update; } @@ -527,7 +461,7 @@ export class LiveMap extends LiveObject= this._objects.gcGracePeriod) { + if (value.tombstone === true && Date.now() - value.tombstonedAt! >= this._realtimeObject.gcGracePeriod) { keysToDelete.push(key); } } @@ -535,13 +469,122 @@ export class LiveMap 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(); + } + + /** + * Returns an in-memory JavaScript object representation of this LiveMap. + * LiveMap values are recursively compacted using their own compact methods. + * Compacted LiveMaps are memoized to handle cyclic references (returned as in-memory pointers). + * + * Use compactJson() for a JSON-serializable representation. + * + * @internal + */ + compact(visitedObjects?: Map>): CompactedValue> { + const visited = visitedObjects ?? new Map>(); + const result: Record = {} as Record; + + // Memoize the compacted result to handle circular references + 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): 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 (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.compactJson(visited); + } + continue; + } + + if (value instanceof LiveCounter) { + result[key] = value.value(); + continue; + } + + // Convert buffers to base64 strings + if (this._client.Platform.BufferUtils.isBuffer(value)) { + result[key] = this._client.Platform.BufferUtils.base64Encode(value); + continue; + } + + // Other values return as is + result[key] = value; + } + + return result; + } + /** @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; @@ -603,10 +646,14 @@ export class LiveMap extends LiveObject = { update: {}, clientId: msg.clientId, connectionId: msg.connectionId }; + const aggregatedUpdate: LiveMapUpdate = { + update: {}, + objectMessage: msg, + _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. @@ -709,12 +756,21 @@ 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: {}, + objectMessage: msg, + _type: 'LiveMapUpdate', + }; const typedKey: keyof T & string = op.key; update.update[typedKey] = 'updated'; @@ -772,6 +840,15 @@ export class LiveMap extends LiveObject extends LiveObject = { update: {}, clientId: msg.clientId, connectionId: msg.connectionId }; + const update: LiveMapUpdate = { + update: {}, + objectMessage: msg, + _type: 'LiveMapUpdate', + }; const typedKey: keyof T & string = op.key; update.update[typedKey] = 'removed'; @@ -873,7 +954,7 @@ export class LiveMap extends LiveObject extends LiveObject extends LiveObject extends LiveObject 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/liveobjects/livemapvaluetype.ts b/src/plugins/liveobjects/livemapvaluetype.ts new file mode 100644 index 0000000000..40ce2a860f --- /dev/null +++ b/src/plugins/liveobjects/livemapvaluetype.ts @@ -0,0 +1,161 @@ +import { __livetype } from '../../../ably'; +import { Primitive, LiveMap as PublicLiveMap, Value } from '../../../liveobjects'; +import { LiveCounterValueType } from './livecountervaluetype'; +import { LiveMap, LiveMapObjectData, ObjectIdObjectData, ValueObjectData } from './livemap'; +import { ObjectId } from './objectid'; +import { + createInitialValueJSONString, + ObjectData, + ObjectMessage, + ObjectOperation, + ObjectOperationAction, + ObjectsMapEntry, + ObjectsMapSemantics, +} 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 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; + + private constructor(entries: T | undefined) { + this._entries = entries; + Object.freeze(this); + } + + static create>( + initialEntries?: 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 + // 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); + } + + Object.entries(entries ?? {}).forEach(([key, value]) => LiveMap.validateKeyValue(realtimeObject, key, value)); + + 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, + }; + } + + 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 Primitive }; + objectData = typedObjectData; + } + + mapEntries[key] = { + data: objectData, + }; + } + + const initialValueOperation = { + map: { + semantics: ObjectsMapSemantics.LWW, + entries: mapEntries, + }, + }; + + return { + initialValueOperation, + nestedObjectsCreateMsgs, + }; + } +} diff --git a/src/plugins/objects/liveobject.ts b/src/plugins/liveobjects/liveobject.ts similarity index 51% rename from src/plugins/objects/liveobject.ts rename to src/plugins/liveobjects/liveobject.ts index 2205fa29ae..55858d9735 100644 --- a/src/plugins/objects/liveobject.ts +++ b/src/plugins/liveobjects/liveobject.ts @@ -1,7 +1,11 @@ import type BaseClient from 'common/lib/client/baseclient'; import type EventEmitter from 'common/lib/util/eventemitter'; +import type { EventCallback, Subscription } from '../../../ably'; +import { ROOT_OBJECT_ID } from './constants'; +import { InstanceEvent } from './instance'; import { ObjectData, ObjectMessage, ObjectOperation } from './objectmessage'; -import { Objects } from './objects'; +import { PathEvent } from './pathobjectsubscriptionregister'; +import { RealtimeObject } from './realtimeobject'; export enum LiveObjectSubscriptionEvent { updated = 'updated', @@ -12,9 +16,13 @@ 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; + /** Indicates whether this update is a result of a tombstone (delete) operation. */ + tombstone?: boolean; } export interface LiveObjectUpdateNoop { @@ -23,27 +31,12 @@ export interface LiveObjectUpdateNoop { noop: true; } -export interface SubscribeResponse { - unsubscribe(): void; -} - -export enum LiveObjectLifecycleEvent { - deleted = 'deleted', -} - -export type LiveObjectLifecycleEventCallback = () => 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, @@ -54,25 +47,28 @@ 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 _objects: Objects, + protected _realtimeObject: RealtimeObject, objectId: string, ) { - this._client = this._objects.getClient(); + 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 this._siteTimeserials = {}; this._createOperationIsMerged = false; this._tombstone = false; + this._parentReferences = new Map>(); } - subscribe(listener: (update: TUpdate) => void): SubscribeResponse { - this._objects.throwIfInvalidAccessApiConfiguration(); - + subscribe(listener: EventCallback): Subscription { this._subscriptions.on(LiveObjectSubscriptionEvent.updated, listener); const unsubscribe = () => { @@ -82,51 +78,6 @@ export abstract class LiveObject< 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); - - 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 */ @@ -136,16 +87,23 @@ 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 { - // should not emit update event if update was noop - if ((update as LiveObjectUpdateNoop).noop) { + if (this._isNoopUpdate(update)) { + // do not emit update events for noop updates return; } - this._subscriptions.emit(LiveObjectSubscriptionEvent.updated, update); + this._notifyInstanceSubscriptions(update); + this._notifyPathSubscriptions(update); + + if (update.tombstone) { + // deregister all listeners if update was a result of a tombstone operation + this._subscriptions.off(); + } } /** @@ -167,9 +125,8 @@ 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; - this._lifecycleEvents.emit(LiveObjectLifecycleEvent.deleted); + update.objectMessage = objectMessage; + update.tombstone = true; return update; } @@ -197,6 +154,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. * @@ -220,6 +273,56 @@ export abstract class LiveObject< return this.tombstone(objectMessage); } + private _notifyInstanceSubscriptions(update: TUpdate): void { + const event: InstanceEvent = { + // 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._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): void { + const paths = this.getFullPaths(); + + if (paths.length === 0) { + // No paths to this object, skip notification + 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: operationObjectMessage, + bubbles: true, + })); + + // For LiveMapUpdate, also create non-bubbling events for each updated key + if (update._type === 'LiveMapUpdate') { + const updatedKeys = Object.keys(update.update); + + for (const key of updatedKeys) { + for (const basePath of paths) { + pathEvents.push({ + path: [...basePath, key], + message: operationObjectMessage, + bubbles: false, + }); + } + } + } + + 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/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 92% rename from src/plugins/objects/objectmessage.ts rename to src/plugins/liveobjects/objectmessage.ts index 5b85ec83cb..c6cbda5af8 100644 --- a/src/plugins/objects/objectmessage.ts +++ b/src/plugins/liveobjects/objectmessage.ts @@ -1,8 +1,19 @@ 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 { JsonArray, JsonObject } from '../../../ably'; +import type * as ObjectsApi from '../../../liveobjects'; + +const operationActions: ObjectsApi.ObjectOperationAction[] = [ + 'map.create', + 'map.set', + 'map.remove', + 'counter.create', + 'counter.inc', + 'object.delete', +]; + +const mapSemantics: ObjectsApi.ObjectsMapSemantics[] = ['lww']; export type EncodeObjectDataFunction = (data: ObjectData | WireObjectData) => WireObjectData; @@ -21,8 +32,6 @@ export enum ObjectsMapSemantics { LWW = 0, } -export type PrimitiveObjectValue = string | number | boolean | Bufferlike | JsonArray | JsonObject; - /** * An ObjectData represents a value in an object on a channel decoded from {@link WireObjectData}. * @spec OD1 @@ -31,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?: PrimitiveObjectValue; + value?: ObjectsApi.Primitive; } /** @@ -45,7 +54,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. */ @@ -70,7 +79,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 } @@ -101,7 +110,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 } @@ -327,6 +336,19 @@ function copyMsg( return result; } +function stringifyOperation(operation: ObjectOperation): ObjectsApi.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 @@ -412,6 +434,30 @@ export class ObjectMessage { toString(): string { return strMsg(this, 'ObjectMessage'); } + + isOperationMessage(): boolean { + return this.operation != null; + } + + isSyncMessage(): boolean { + return this.object != null; + } + + toUserFacingMessage(channel: RealtimeChannel): ObjectsApi.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, + }; + } } /** @@ -726,23 +772,28 @@ export class WireObjectMessage { format: Utils.Format | undefined, ): ObjectData { try { - let decodedBytes: Bufferlike | undefined; + if (objectData.objectId != null) { + return { + objectId: objectData.objectId, + }; + } + + 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)); } - let decodedJson: JsonObject | JsonArray | undefined; + let decodedJson: ObjectsApi.JsonObject | ObjectsApi.JsonArray | undefined; if (objectData.json != null) { decodedJson = JSON.parse(objectData.json); // OD5a2, OD5b3 } return { - objectId: objectData.objectId, value: decodedBytes ?? decodedJson ?? objectData.boolean ?? objectData.number ?? objectData.string, }; } catch (error) { diff --git a/src/plugins/objects/objectspool.ts b/src/plugins/liveobjects/objectspool.ts similarity index 78% rename from src/plugins/objects/objectspool.ts rename to src/plugins/liveobjects/objectspool.ts index 7401924813..f9f1fe7bd2 100644 --- a/src/plugins/objects/objectspool.ts +++ b/src/plugins/liveobjects/objectspool.ts @@ -1,12 +1,11 @@ import type BaseClient from 'common/lib/client/baseclient'; +import { ROOT_OBJECT_ID } from './constants'; import { DEFAULTS } from './defaults'; import { LiveCounter } from './livecounter'; import { LiveMap } from './livemap'; import { LiveObject } from './liveobject'; import { ObjectId } from './objectid'; -import { Objects } from './objects'; - -export const ROOT_OBJECT_ID = 'root'; +import { RealtimeObject } from './realtimeobject'; /** * @internal @@ -17,8 +16,8 @@ export class ObjectsPool { private _pool: Map; // RTO3a private _gcInterval: ReturnType; - constructor(private _objects: Objects) { - this._client = this._objects.getClient(); + constructor(private _realtimeObject: RealtimeObject) { + this._client = this._realtimeObject.getClient(); this._pool = this._createInitialPool(); this._gcInterval = setInterval(() => { this._onGCInterval(); @@ -31,6 +30,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 +62,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); @@ -82,12 +93,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 +109,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; } @@ -107,9 +118,9 @@ 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._objects.gcGracePeriod) { + if (obj.isTombstoned() && Date.now() - obj.tombstonedAt()! >= this._realtimeObject.gcGracePeriod) { toDelete.push(objectId); continue; } diff --git a/src/plugins/liveobjects/pathobject.ts b/src/plugins/liveobjects/pathobject.ts new file mode 100644 index 0000000000..c18b9af18f --- /dev/null +++ b/src/plugins/liveobjects/pathobject.ts @@ -0,0 +1,484 @@ +import type BaseClient from 'common/lib/client/baseclient'; +import type { EventCallback, Subscription } from '../../../ably'; +import type { + AnyPathObject, + BatchContext, + BatchFunction, + CompactedJsonValue, + CompactedValue, + Instance, + LiveObject as LiveObjectType, + PathObject, + PathObjectSubscriptionEvent, + PathObjectSubscriptionOptions, + Primitive, + Value, +} from '../../../liveobjects'; +import { DefaultInstance } from './instance'; +import { LiveCounter } from './livecounter'; +import { LiveMap } from './livemap'; +import { LiveObject } from './liveobject'; +import { RealtimeObject } from './realtimeobject'; +import { RootBatchContext } from './rootbatchcontext'; + +/** + * Implementation of AnyPathObject interface. + * Provides a generic implementation that can handle any type of PathObject operations. + */ +export class DefaultPathObject implements AnyPathObject { + private _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 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 returned as-is. + * For primitive types, this is an alias for calling value(). + * + * Use compactJson() for a JSON-serializable representation. + */ + compact(): CompactedValue | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + + 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; + } + } + + /** + * 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 { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + + 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 CompactedJsonValue; + } + + return value as CompactedJsonValue; + } 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; + } + } + + /** + * 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(/(?; + } + + /** + * Get the current value at this path. + * 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); + + 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; + } + } + + instance(): Instance | undefined { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + + try { + return this._resolveInstance(); + } 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]> { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + + 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 { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + + 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; + } + } + + /** + * 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 { + this._realtimeObject.throwIfInvalidAccessApiConfiguration(); + + 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 { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); + + 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 { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); + + 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 { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); + + 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 { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); + + 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); + } + + /** + * 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, + ): 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; + }); + } + + async batch(fn: BatchFunction): Promise { + this._realtimeObject.throwIfInvalidWriteApiConfiguration(); + + 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 { + let current: Value = this._root; + + 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 _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/liveobjects/pathobjectsubscriptionregister.ts b/src/plugins/liveobjects/pathobjectsubscriptionregister.ts new file mode 100644 index 0000000000..3b71f65d9c --- /dev/null +++ b/src/plugins/liveobjects/pathobjectsubscriptionregister.ts @@ -0,0 +1,207 @@ +import type BaseClient from 'common/lib/client/baseclient'; +import type { EventCallback, Subscription } from '../../../ably'; +import type { PathObjectSubscriptionEvent, PathObjectSubscriptionOptions } from '../../../liveobjects'; +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; + /** 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, + ): Subscription { + 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()), + }; + + 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/objects.ts b/src/plugins/liveobjects/realtimeobject.ts similarity index 72% rename from src/plugins/objects/objects.ts rename to src/plugins/liveobjects/realtimeobject.ts index 907509134a..7dc1edfb9e 100644 --- a/src/plugins/objects/objects.ts +++ b/src/plugins/liveobjects/realtimeobject.ts @@ -1,14 +1,16 @@ 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 type { ChannelState, StatusSubscription } from '../../../ably'; +import type * as ObjectsApi from '../../../liveobjects'; import { DEFAULTS } from './defaults'; 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 { @@ -30,13 +32,7 @@ const StateToEventsMap: Record = { export type ObjectsEventCallback = () => void; -export interface OnObjectsEventResponse { - off(): void; -} - -export type BatchCallback = (batchContext: BatchContext) => void; - -export class Objects { +export class RealtimeObject { gcGracePeriod: number; private _client: BaseClient; @@ -52,6 +48,7 @@ export class Objects { private _currentSyncId: string | undefined; private _currentSyncCursor: string | undefined; private _bufferedObjectOperations: ObjectMessage[]; + private _pathObjectSubscriptionRegister: PathObjectSubscriptionRegister; // Used by tests static _DEFAULTS = DEFAULTS; @@ -65,6 +62,7 @@ export class Objects { 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; @@ -75,102 +73,25 @@ export class Objects { /** * 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> { - this.throwIfInvalidAccessApiConfiguration(); // RTO1a, RTO1b + async get>(): Promise>> { + 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) { await this._eventEmitterInternal.once(ObjectsEvent.synced); // RTO1c } - return this._objectsPool.get(ROOT_OBJECT_ID) as LiveMap; // RTO1d + const pathObject = new DefaultPathObject(this, this._objectsPool.getRoot(), []); + 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.getRoot(); - const context = new BatchContext(this, root); - - try { - callback(context); - await context.flush(); - } finally { - context.close(); - } - } - - /** - * 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 { + 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); @@ -192,11 +113,6 @@ export class Objects { 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 */ @@ -218,6 +134,13 @@ export class Objects { return this._client; } + /** + * @internal + */ + getPathObjectSubscriptionRegister(): PathObjectSubscriptionRegister { + return this._pathObjectSubscriptionRegister; + } + /** * @internal * @spec RTO5 @@ -263,7 +186,7 @@ export class Objects { this._client.Logger.logAction( this._client.logger, this._client.Logger.LOG_MINOR, - 'Objects.onAttached()', + 'RealtimeObject.onAttached()', `channel=${this._channel.name}, hasObjects=${hasObjects}`, ); @@ -283,7 +206,7 @@ export class Objects { /** * @internal */ - actOnChannelState(state: API.ChannelState, hasObjects?: boolean): void { + actOnChannelState(state: ChannelState, hasObjects?: boolean): void { switch (state) { case 'attached': this.onAttached(hasObjects); @@ -383,7 +306,10 @@ export class Objects { } const receivedObjectIds = new Set(); - const existingObjectUpdates: { object: LiveObject; update: LiveObjectUpdate | LiveObjectUpdateNoop }[] = []; + const existingObjectUpdates: { + object: LiveObject; + update: LiveObjectUpdate | LiveObjectUpdateNoop; + }[] = []; // RTO5c1 for (const [objectId, entry] of this._syncObjectsDataPool.entries()) { @@ -422,7 +348,11 @@ export class Objects { // RTO5c2 - need to remove LiveObject instances from the ObjectsPool for which objectIds were not received during the sync sequence this._objectsPool.deleteExtraObjectIds([...receivedObjectIds]); - // call subscription callbacks for all updated existing objects + // 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)); } @@ -432,7 +362,7 @@ export class Objects { this._client.Logger.logAction( this._client.logger, this._client.Logger.LOG_MAJOR, - 'Objects._applyObjectMessages()', + 'RealtimeObject._applyObjectMessages()', `object operation message is received without 'operation' field, skipping message; message id: ${objectMessage.id}, channel: ${this._channel.name}`, ); continue; @@ -461,7 +391,7 @@ export class Objects { this._client.Logger.logAction( this._client.logger, this._client.Logger.LOG_MAJOR, - 'Objects._applyObjectMessages()', + 'RealtimeObject._applyObjectMessages()', `received unsupported action in object operation message: ${objectOperation.action}, skipping message; message id: ${objectMessage.id}, channel: ${this._channel.name}`, ); } @@ -495,7 +425,32 @@ export class Objects { this._eventEmitterPublic.emit(event); } - private _throwIfInChannelState(channelState: API.ChannelState[]): void { + /** + * 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: ChannelState[]): void { if (channelState.includes(this._channel.state)) { throw this._client.ErrorInfo.fromValues(this._channel.invalidStateError()); } diff --git a/src/plugins/liveobjects/rootbatchcontext.ts b/src/plugins/liveobjects/rootbatchcontext.ts new file mode 100644 index 0000000000..ed93c0e32e --- /dev/null +++ b/src/plugins/liveobjects/rootbatchcontext.ts @@ -0,0 +1,73 @@ +import type { Instance, Value } from '../../../liveobjects'; +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/src/plugins/objects/syncobjectsdatapool.ts b/src/plugins/liveobjects/syncobjectsdatapool.ts similarity index 92% rename from src/plugins/objects/syncobjectsdatapool.ts rename to src/plugins/liveobjects/syncobjectsdatapool.ts index f893b18240..31f6eeb5c8 100644 --- a/src/plugins/objects/syncobjectsdatapool.ts +++ b/src/plugins/liveobjects/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 { RealtimeObject } from './realtimeobject'; export interface LiveObjectDataEntry { objectMessage: ObjectMessage; @@ -27,9 +27,9 @@ export class SyncObjectsDataPool { private _channel: RealtimeChannel; private _pool: Map; - constructor(private _objects: Objects) { - 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/src/plugins/objects/batchcontext.ts b/src/plugins/objects/batchcontext.ts deleted file mode 100644 index 8e6bc2e76c..0000000000 --- a/src/plugins/objects/batchcontext.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type BaseClient from 'common/lib/client/baseclient'; -import type * as API from '../../../ably'; -import { BatchContextLiveCounter } from './batchcontextlivecounter'; -import { BatchContextLiveMap } from './batchcontextlivemap'; -import { LiveCounter } from './livecounter'; -import { LiveMap } from './livemap'; -import { ObjectMessage } from './objectmessage'; -import { Objects } from './objects'; -import { ROOT_OBJECT_ID } from './objectspool'; - -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; - - constructor( - private _objects: Objects, - private _root: LiveMap, - ) { - this._client = _objects.getClient(); - this._wrappedObjects.set(this._root.getObjectId(), new BatchContextLiveMap(this, this._objects, this._root)); - } - - getRoot(): BatchContextLiveMap { - this._objects.throwIfInvalidAccessApiConfiguration(); - this.throwIfClosed(); - return this.getWrappedObject(ROOT_OBJECT_ID) as BatchContextLiveMap; - } - - /** - * @internal - */ - getWrappedObject(objectId: string): BatchContextLiveCounter | BatchContextLiveMap | undefined { - if (this._wrappedObjects.has(objectId)) { - return this._wrappedObjects.get(objectId); - } - - const originObject = this._objects.getPool().get(objectId); - if (!originObject) { - return undefined; - } - - let wrappedObject: BatchContextLiveCounter | BatchContextLiveMap; - if (originObject instanceof LiveMap) { - wrappedObject = new BatchContextLiveMap(this, this._objects, originObject); - } else if (originObject instanceof LiveCounter) { - wrappedObject = new BatchContextLiveCounter(this, this._objects, originObject); - } else { - throw new this._client.ErrorInfo( - `Unknown LiveObject instance type: objectId=${originObject.getObjectId()}`, - 50000, - 500, - ); - } - - this._wrappedObjects.set(objectId, wrappedObject); - return wrappedObject; - } - - /** - * @internal - */ - throwIfClosed(): void { - if (this.isClosed()) { - throw new this._client.ErrorInfo('Batch is closed', 40000, 400); - } - } - - /** - * @internal - */ - isClosed(): boolean { - return this._isClosed; - } - - /** - * @internal - */ - close(): void { - this._isClosed = true; - } - - /** - * @internal - */ - queueMessage(msg: ObjectMessage): void { - this._queuedMessages.push(msg); - } - - /** - * @internal - */ - async flush(): Promise { - try { - this.close(); - - if (this._queuedMessages.length > 0) { - await this._objects.publish(this._queuedMessages); - } - } finally { - this._wrappedObjects.clear(); - this._queuedMessages = []; - } - } -} diff --git a/src/plugins/objects/batchcontextlivecounter.ts b/src/plugins/objects/batchcontextlivecounter.ts deleted file mode 100644 index 7f7b6ac1d3..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 { Objects } from './objects'; - -export class BatchContextLiveCounter { - private _client: BaseClient; - - constructor( - private _batchContext: BatchContext, - private _objects: Objects, - private _counter: LiveCounter, - ) { - this._client = this._objects.getClient(); - } - - value(): number { - this._objects.throwIfInvalidAccessApiConfiguration(); - this._batchContext.throwIfClosed(); - return this._counter.value(); - } - - increment(amount: number): void { - this._objects.throwIfInvalidWriteApiConfiguration(); - this._batchContext.throwIfClosed(); - const msg = LiveCounter.createCounterIncMessage(this._objects, this._counter.getObjectId(), amount); - this._batchContext.queueMessage(msg); - } - - decrement(amount: number): void { - this._objects.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 306b313426..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 { Objects } from './objects'; - -export class BatchContextLiveMap { - constructor( - private _batchContext: BatchContext, - private _objects: Objects, - private _map: LiveMap, - ) {} - - get(key: TKey): T[TKey] | undefined { - this._objects.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._objects.throwIfInvalidAccessApiConfiguration(); - this._batchContext.throwIfClosed(); - return this._map.size(); - } - - *entries(): IterableIterator<[TKey, T[TKey]]> { - this._objects.throwIfInvalidAccessApiConfiguration(); - this._batchContext.throwIfClosed(); - yield* this._map.entries(); - } - - *keys(): IterableIterator { - this._objects.throwIfInvalidAccessApiConfiguration(); - this._batchContext.throwIfClosed(); - yield* this._map.keys(); - } - - *values(): IterableIterator { - this._objects.throwIfInvalidAccessApiConfiguration(); - this._batchContext.throwIfClosed(); - yield* this._map.values(); - } - - set(key: TKey, value: T[TKey]): void { - this._objects.throwIfInvalidWriteApiConfiguration(); - this._batchContext.throwIfClosed(); - const msg = LiveMap.createMapSetMessage(this._objects, this._map.getObjectId(), key, value); - this._batchContext.queueMessage(msg); - } - - remove(key: TKey): void { - this._objects.throwIfInvalidWriteApiConfiguration(); - this._batchContext.throwIfClosed(); - const msg = LiveMap.createMapRemoveMessage(this._objects, this._map.getObjectId(), key); - this._batchContext.queueMessage(msg); - } -} diff --git a/src/plugins/objects/index.ts b/src/plugins/objects/index.ts deleted file mode 100644 index 6bba742bee..0000000000 --- a/src/plugins/objects/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ObjectMessage, WireObjectMessage } from './objectmessage'; -import { Objects } from './objects'; - -export { Objects, ObjectMessage, WireObjectMessage }; - -export default { - Objects, - ObjectMessage, - WireObjectMessage, -}; 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/common/modules/private_api_recorder.js b/test/common/modules/private_api_recorder.js index 04e597257f..338fe3ffb1 100644 --- a/test/common/modules/private_api_recorder.js +++ b/test/common/modules/private_api_recorder.js @@ -14,11 +14,9 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths) 'call.Defaults.getPort', 'call.Defaults.normaliseOptions', 'call.EventEmitter.emit', - 'call.LiveObject.getObjectId', + 'call.EventEmitter.listeners', '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,9 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths) 'call.Platform.nextTick', 'call.PresenceMessage.fromValues', 'call.ProtocolMessage.setFlag', + 'call.RealtimeObject._objectsPool._onGCInterval', + 'call.RealtimeObject._objectsPool.get', + 'call.RealtimeObject.getPathObjectSubscriptionRegister', 'call.Utils.copy', 'call.Utils.dataSizeBytes', 'call.Utils.getRetryTime', @@ -77,14 +78,17 @@ 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.Objects._DEFAULTS.gcGracePeriod', - 'read.Objects.gcGracePeriod', + 'read.LiveMap._dataRef.data', + 'read.LiveObject._subscriptions', + 'read.PathObjectSubscriptionRegister._subscriptions', 'read.Platform.Config.push', 'read.ProtocolMessage.channelSerial', 'read.Realtime._transports', + 'read.RealtimeObject._DEFAULTS.gcGracePeriod', + 'read.RealtimeObject.gcGracePeriod', 'read.auth.authOptions.authUrl', 'read.auth.key', 'read.auth.method', @@ -124,8 +128,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.RealtimeObject._objectsPool._onGCInterval', + 'replace.RealtimeObject.publish', 'replace.channel.attachImpl', 'replace.channel.processMessage', 'replace.channel.sendMessage', @@ -143,9 +147,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.RealtimeObject._DEFAULTS.gcInterval', + 'write.RealtimeObject.gcGracePeriod', 'write.auth.authOptions.requestHeaders', 'write.auth.key', 'write.auth.tokenDetails.token', diff --git a/test/package/browser/template/README.md b/test/package/browser/template/README.md index cc5cbc54c2..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-liveobjects.ts b/test/package/browser/template/src/index-liveobjects.ts new file mode 100644 index 0000000000..1603637f47 --- /dev/null +++ b/test/package/browser/template/src/index-liveobjects.ts @@ -0,0 +1,122 @@ +import { Realtime } from 'ably'; +import { + AnyPathObject, + CompactedJsonValue, + CompactedValue, + LiveCounter, + LiveCounterPathObject, + LiveMap, + LiveMapPathObject, + LiveObjects, + ObjectMessage, + PathObject, +} from 'ably/liveobjects'; +import { createSandboxAblyAPIKey } from './sandbox'; + +// Fix for "type 'typeof globalThis' has no index signature" error: +// https://stackoverflow.com/questions/68481686/type-typeof-globalthis-has-no-index-signature +declare module globalThis { + var testAblyPackage: () => Promise; +} + +type MyCustomObject = { + numberKey: number; + stringKey: string; + booleanKey: boolean; + couldBeUndefined?: string; + mapKey: LiveMap<{ + foo: 'bar'; + nestedMap?: LiveMap<{ + baz: 'qux'; + }>; + }>; + counterKey: LiveCounter; + arrayBufferKey: ArrayBuffer; + bufferKey: Buffer; +}; + +globalThis.testAblyPackage = async function () { + const key = await createSandboxAblyAPIKey(); + + 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 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 + const size: number | undefined = myObject.size(); + + // 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: + 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 + const foo: 'bar' = map?.get('foo')?.value()!; + const baz: 'qux' = map?.get('nestedMap')?.get('baz')?.value()!; + // check LiveCounter type also behaves as expected + const value: number = counter?.value()!; + + // check subscription callback has correct TypeScript types + const { unsubscribe } = myObject.subscribe(({ object, message }) => { + const typedObject: AnyPathObject = object; + const typedMessage: ObjectMessage | undefined = message; + }); + unsubscribe(); + + // compact values + 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: 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; + } + | { objectId: string } + | undefined = compactJson; +}; diff --git a/test/package/browser/template/src/index-objects.ts b/test/package/browser/template/src/index-objects.ts deleted file mode 100644 index a9442a3fe7..0000000000 --- a/test/package/browser/template/src/index-objects.ts +++ /dev/null @@ -1,95 +0,0 @@ -import * as Ably from 'ably'; -import Objects from 'ably/objects'; -import { createSandboxAblyAPIKey } from './sandbox'; - -// Fix for "type 'typeof globalThis' has no index signature" error: -// https://stackoverflow.com/questions/68481686/type-typeof-globalthis-has-no-index-signature -declare module globalThis { - var testAblyPackage: () => Promise; -} - -type CustomRoot = { - numberKey: number; - stringKey: string; - booleanKey: boolean; - couldBeUndefined?: string; - mapKey: Ably.LiveMap<{ - foo: 'bar'; - nestedMap?: Ably.LiveMap<{ - baz: 'qux'; - }>; - }>; - counterKey: Ably.LiveCounter; -}; - -declare global { - export interface AblyObjectsTypes { - root: CustomRoot; - } -} - -type ExplicitRootType = { - someOtherKey: string; -}; - -globalThis.testAblyPackage = async function () { - const key = await createSandboxAblyAPIKey(); - - 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 - 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(); - - // check root has expected LiveMap TypeScript type methods - const size: number = root.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'); - // check string literal types works - // need to use nullish coalescing as we didn't actually create any data on the root, - // 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 }) => { - // 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(); - - // 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; - }); - counterSubscribeResponse?.unsubscribe(); - - // check can provide custom types for the getRoot method, ignoring global AblyObjectsTypes interface - const explicitRoot: Ably.LiveMap = await objects.getRoot(); - const someOtherKey: string | undefined = explicitRoot.get('someOtherKey'); -}; 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/liveobjects.test.js b/test/realtime/liveobjects.test.js new file mode 100644 index 0000000000..d4d89591a4 --- /dev/null +++ b/test/realtime/liveobjects.test.js @@ -0,0 +1,8400 @@ +'use strict'; + +define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], function ( + Ably, + Helper, + chai, + 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({ LiveObjectsPlugin }); + const liveobjectsFixturesChannel = 'liveobjects_fixtures'; + const nextTick = Ably.Realtime.Platform.Config.nextTick; + const gcIntervalOriginal = LiveObjectsPlugin.RealtimeObject._DEFAULTS.gcInterval; + const LiveMap = LiveObjectsPlugin.LiveMap; + const LiveCounter = LiveObjectsPlugin.LiveCounter; + + function RealtimeWithLiveObjects(helper, options) { + return helper.AblyRealtime({ ...options, plugins: { LiveObjects: LiveObjectsPlugin } }); + } + + function channelOptionsWithObjectModes(options) { + return { + ...options, + modes: ['OBJECT_SUBSCRIBE', 'OBJECT_PUBLISH'], + }; + } + + function expectInstanceOf(object, className, msg) { + // esbuild changes the name for classes with static method to include an underscore as prefix. + // so LiveMap becomes _LiveMap. we account for it here. + expect(object.constructor.name).to.match(new RegExp(`_?${className}`), msg); + } + + function forScenarios(thisInDescribe, scenarios, testFn) { + for (const scenario of scenarios) { + if (scenario.allTransportsAndProtocols) { + Helper.testOnAllTransportsAndProtocols( + thisInDescribe, + scenario.description, + function (options, channelName) { + return async function () { + const helper = this.test.helper; + await testFn(helper, scenario, options, channelName); + }; + }, + scenario.skip, + scenario.only, + ); + } else { + const itFn = scenario.skip ? it.skip : scenario.only ? it.only : it; + + itFn(scenario.description, async function () { + const helper = this.test.helper; + await testFn(helper, scenario, {}, scenario.description); + }); + } + } + } + + function lexicoTimeserial(seriesId, timestamp, counter, index) { + const paddedTimestamp = timestamp.toString().padStart(14, '0'); + const paddedCounter = counter.toString().padStart(3, '0'); + const paddedIndex = index != null ? index.toString().padStart(3, '0') : undefined; + + // Example: + // + // 01726585978590-001@abcdefghij:001 + // |____________| |_| |________| |_| + // | | | | + // timestamp counter seriesId idx + return `${paddedTimestamp}-${paddedCounter}@${seriesId}` + (paddedIndex ? `:${paddedIndex}` : ''); + } + + 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; + + return savedError; + } + + function objectMessageFromValues(values) { + return LiveObjectsPlugin.ObjectMessage.fromValues(values, Utils, MessageEncoding); + } + + async function waitForMapKeyUpdate(mapInstance, key) { + return new Promise((resolve) => { + const { unsubscribe } = mapInstance.subscribe(({ message }) => { + if (message?.operation?.mapOp?.key === key) { + unsubscribe(); + resolve(); + } + }); + }); + } + + async function waitForCounterUpdate(counterInstance) { + return new Promise((resolve) => { + const { unsubscribe } = counterInstance.subscribe(() => { + unsubscribe(); + resolve(); + }); + }); + } + + async function waitForObjectOperation(helper, client, waitForAction) { + return new Promise((resolve, reject) => { + helper.recordPrivateApi('call.connectionManager.activeProtocol.getTransport'); + const transport = client.connection.connectionManager.activeProtocol.getTransport(); + const onProtocolMessageOriginal = transport.onProtocolMessage; + + helper.recordPrivateApi('replace.transport.onProtocolMessage'); + transport.onProtocolMessage = function (message) { + try { + helper.recordPrivateApi('call.transport.onProtocolMessage'); + onProtocolMessageOriginal.call(transport, message); + + if (message.action === 19 && message.state[0]?.operation?.action === waitForAction) { + helper.recordPrivateApi('replace.transport.onProtocolMessage'); + transport.onProtocolMessage = onProtocolMessageOriginal; + resolve(); + } + } catch (err) { + reject(err); + } + }; + }); + } + + async function waitForObjectSync(helper, client) { + return new Promise((resolve, reject) => { + helper.recordPrivateApi('call.connectionManager.activeProtocol.getTransport'); + const transport = client.connection.connectionManager.activeProtocol.getTransport(); + const onProtocolMessageOriginal = transport.onProtocolMessage; + + helper.recordPrivateApi('replace.transport.onProtocolMessage'); + transport.onProtocolMessage = function (message) { + try { + helper.recordPrivateApi('call.transport.onProtocolMessage'); + onProtocolMessageOriginal.call(transport, message); + + if (message.action === 20) { + helper.recordPrivateApi('replace.transport.onProtocolMessage'); + transport.onProtocolMessage = onProtocolMessageOriginal; + resolve(); + } + } catch (err) { + reject(err); + } + }; + }); + } + + /** + * 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(liveobjectsFixturesChannel, channelOptionsWithObjectModes()); + const expectedKeys = LiveObjectsHelper.fixtureRootKeys(); + + await channel.attach(); + const entryPathObject = await channel.object.get(); + const entryInstance = entryPathObject.instance(); + + await Promise.all( + expectedKeys.map((key) => (entryInstance.get(key) ? undefined : waitForMapKeyUpdate(entryInstance, key))), + ); + } + + describe('realtime/liveobjects', function () { + this.timeout(60 * 1000); + + before(function (done) { + const helper = Helper.forHook(this); + + helper.setupApp(function (err) { + if (err) { + done(err); + return; + } + + new LiveObjectsHelper(helper) + .initForChannel(liveobjectsFixturesChannel) + .then(done) + .catch((err) => done(err)); + }); + }); + + 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('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 LiveObjectsHelper(helper); + const testClient = helper.AblyRealtime(); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + const testChannel = testClient.channels.get('channel'); + await testChannel.attach(); + + const receivedMessagePromise = new Promise((resolve) => testChannel.subscribe(resolve)); + + const publishClient = helper.AblyRealtime(); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + // inject OBJECT message that should be ignored and not break anything without the plugin + await objectsHelper.processObjectOperationMessageOnChannel({ + channel: testChannel, + serial: lexicoTimeserial('aaa', 0, 0), + siteCode: 'aaa', + state: [objectsHelper.mapSetOp({ objectId: 'root', key: 'stringKey', data: { string: 'stringValue' } })], + }); + + const publishChannel = publishClient.channels.get('channel'); + await publishChannel.publish(null, 'test'); + + // regular message subscriptions should still work after processing OBJECT_SYNC message without the plugin + await receivedMessagePromise; + }, publishClient); + }, testClient); + }); + + /** @nospec */ + it(`doesn't break when it receives an OBJECT_SYNC ProtocolMessage`, async function () { + const helper = this.test.helper; + const objectsHelper = new LiveObjectsHelper(helper); + const testClient = helper.AblyRealtime(); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + const testChannel = testClient.channels.get('channel'); + await testChannel.attach(); + + const receivedMessagePromise = new Promise((resolve) => testChannel.subscribe(resolve)); + + const publishClient = helper.AblyRealtime(); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + // inject OBJECT_SYNC message that should be ignored and not break anything without the plugin + await objectsHelper.processObjectStateMessageOnChannel({ + channel: testChannel, + syncSerial: 'serial:', + state: [ + objectsHelper.mapObject({ + objectId: 'root', + siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, + }), + ], + }); + + const publishChannel = publishClient.channels.get('channel'); + await publishChannel.publish(null, 'test'); + + // regular message subscriptions should still work after processing OBJECT_SYNC message without the plugin + await receivedMessagePromise; + }, publishClient); + }, testClient); + }); + }); + + 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 = RealtimeWithLiveObjects(helper, { autoConnect: false }); + const channel = client.channels.get('channel'); + expectInstanceOf(channel.object, 'RealtimeObject'); + }); + + /** @nospec */ + it('RealtimeObject.get() returns LiveObject with id "root"', async function () { + const helper = this.test.helper; + const client = RealtimeWithLiveObjects(helper); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + const channel = client.channels.get('channel', channelOptionsWithObjectModes()); + + await channel.attach(); + const entryPathObject = await channel.object.get(); + + expect(entryPathObject.instance().id).to.equal('root', 'root object should have an object id "root"'); + }, client); + }); + + /** @nospec */ + it('RealtimeObject.get() returns empty root when no objects exist on a channel', async function () { + const helper = this.test.helper; + const client = RealtimeWithLiveObjects(helper); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + const channel = client.channels.get('channel', channelOptionsWithObjectModes()); + + await channel.attach(); + const entryPathObject = await channel.object.get(); + + expect(entryPathObject.size()).to.equal(0, 'Check root has no keys'); + }, client); + }); + + /** @nospec */ + it('RealtimeObject.get() waits for initial OBJECT_SYNC to be completed before resolving', async function () { + const helper = this.test.helper; + const client = RealtimeWithLiveObjects(helper); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + const channel = client.channels.get('channel', channelOptionsWithObjectModes()); + + const getPromise = channel.object.get(); + + let getResolved = false; + getPromise.then(() => { + getResolved = true; + }); + + // 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(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 getPromise; + }, client); + }); + + /** @nospec */ + it('RealtimeObject.get() resolves immediately when OBJECT_SYNC sequence is completed', async function () { + const helper = this.test.helper; + const client = RealtimeWithLiveObjects(helper); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + const channel = client.channels.get('channel', channelOptionsWithObjectModes()); + + await channel.attach(); + // wait for sync sequence to complete by accessing root for the first time + await channel.object.get(); + + let resolvedImmediately = false; + channel.object.get().then(() => { + resolvedImmediately = true; + }); + + // wait for next tick for RealtimeObject.get() handler to process + helper.recordPrivateApi('call.Platform.nextTick'); + await new Promise((res) => nextTick(res)); + + expect(resolvedImmediately, 'Check RealtimeObject.get() is resolved on next tick').to.be.true; + }, client); + }); + + /** @nospec */ + it('RealtimeObject.get() waits for OBJECT_SYNC with empty cursor before resolving', async function () { + const helper = this.test.helper; + const objectsHelper = new LiveObjectsHelper(helper); + const client = RealtimeWithLiveObjects(helper); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + const channel = client.channels.get('channel', channelOptionsWithObjectModes()); + + await channel.attach(); + // wait for initial sync sequence to complete + await channel.object.get(); + + // inject OBJECT_SYNC message to emulate start of a new sequence + await objectsHelper.processObjectStateMessageOnChannel({ + channel, + // have cursor so client awaits for additional OBJECT_SYNC messages + syncSerial: 'serial:cursor', + }); + + let getResolved = false; + let entryInstance; + channel.object.get().then((value) => { + getResolved = true; + entryInstance = value; + }); + + // 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(getResolved, 'Check RealtimeObject.get() is not resolved while OBJECT_SYNC is in progress').to.be + .false; + + // inject final OBJECT_SYNC message + await objectsHelper.processObjectStateMessageOnChannel({ + channel, + // no cursor to indicate the end of OBJECT_SYNC messages + syncSerial: 'serial:', + state: [ + objectsHelper.mapObject({ + objectId: 'root', + siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, + initialEntries: { key: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { number: 1 } } }, + }), + ], + }); + + // wait for next tick for RealtimeObject.get() handler to process + helper.recordPrivateApi('call.Platform.nextTick'); + await new Promise((res) => nextTick(res)); + + expect(getResolved, 'Check RealtimeObject.get() is resolved when OBJECT_SYNC sequence has ended').to.be.true; + expect(entryInstance.get('key').value()).to.equal( + 1, + 'Check new root after OBJECT_SYNC sequence has expected key', + ); + }, client); + }); + + /** @nospec */ + it('RealtimeObject.get() on unattached channel implicitly attaches and waits for sync', async function () { + const helper = this.test.helper; + const client = RealtimeWithLiveObjects(helper); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + 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 + 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'); + helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); + expect( + BufferUtils.areBuffersEqual(pathObject.get(key).value(), BufferUtils.base64Decode(keyData.data.bytes)), + msg, + ).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); + } else { + const expectedValue = keyData.data.string ?? keyData.data.number ?? keyData.data.boolean; + expect(pathObject.get(key).value()).to.equal(expectedValue, msg); + } + } + + 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: '' } }, + { key: 'bytesKey', data: { bytes: 'eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9' } }, + { key: 'emptyBytesKey', data: { bytes: '' } }, + { key: 'maxSafeIntegerKey', data: { number: Number.MAX_SAFE_INTEGER } }, + { key: 'negativeMaxSafeIntegerKey', data: { number: -Number.MAX_SAFE_INTEGER } }, + { key: 'numberKey', data: { number: 1 } }, + { key: 'zeroKey', data: { number: 0 } }, + { key: 'trueKey', data: { boolean: true } }, + { key: 'falseKey', data: { boolean: false } }, + { key: 'objectKey', data: { json: JSON.stringify({ foo: 'bar' }) } }, + { key: 'arrayKey', data: { json: JSON.stringify(['foo', 'bar', 'baz']) } }, + ]; + const primitiveMapsFixtures = [ + { name: 'emptyMap' }, + { + name: 'valuesMap', + entries: primitiveKeyData.reduce((acc, v) => { + acc[v.key] = { data: v.data }; + return acc; + }, {}), + restData: primitiveKeyData.reduce((acc, v) => { + acc[v.key] = v.data; + return acc; + }, {}), + }, + ]; + const countersFixtures = [ + { name: 'emptyCounter' }, + { name: 'zeroCounter', count: 0 }, + { name: 'valueCounter', count: 10 }, + { name: 'negativeValueCounter', count: -10 }, + { name: 'maxSafeIntegerCounter', count: Number.MAX_SAFE_INTEGER }, + { name: 'negativeMaxSafeIntegerCounter', count: -Number.MAX_SAFE_INTEGER }, + ]; + + const objectSyncSequenceScenarios = [ + { + allTransportsAndProtocols: true, + description: 'OBJECT_SYNC sequence builds object tree on channel attachment', + action: async (ctx) => { + const { client, helper } = ctx; + + await waitFixtureChannelIsReady(client); + + const channel = client.channels.get(liveobjectsFixturesChannel, channelOptionsWithObjectModes()); + + await channel.attach(); + 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(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 = entryInstance.get(key); + expect(counter, `Check counter at key="${key}" in root exists`).to.exist; + 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 = entryInstance.get(key); + expect(map, `Check map at key="${key}" in root exists`).to.exist; + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf(map._value, 'LiveMap', `Check map at key="${key}" in root is of type LiveMap`); + }); + + const valuesMap = entryInstance.get('valuesMap'); + const valueMapKeys = [ + 'stringKey', + 'emptyStringKey', + 'bytesKey', + 'emptyBytesKey', + 'maxSafeIntegerKey', + 'negativeMaxSafeIntegerKey', + 'numberKey', + 'zeroKey', + 'trueKey', + 'falseKey', + 'objectKey', + 'arrayKey', + 'mapKey', + ]; + expect(valuesMap.size()).to.equal(valueMapKeys.length, 'Check nested map has correct number of keys'); + valueMapKeys.forEach((key) => { + expect(valuesMap.get(key), `Check value at key="${key}" in nested map exists`).to.exist; + }); + }, + }, + + { + allTransportsAndProtocols: true, + description: 'OBJECT_SYNC sequence builds object tree with all operations applied', + action: async (ctx) => { + const { helper, clientOptions, channelName, entryInstance } = ctx; + + const objectsCreatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), + ]); + await Promise.all([ + // MAP_CREATE + entryInstance.set('map', LiveMap.create({ shouldStay: 'foo', shouldDelete: 'bar' })), + // COUNTER_CREATE + entryInstance.set('counter', LiveCounter.create(1)), + objectsCreatedPromise, + ]); + + const map = entryInstance.get('map'); + const counter = entryInstance.get('counter'); + + const operationsAppliedPromise = Promise.all([ + waitForMapKeyUpdate(map, 'anotherKey'), + waitForMapKeyUpdate(map, 'shouldDelete'), + waitForCounterUpdate(counter), + ]); + + await Promise.all([ + // MAP_SET + map.set('anotherKey', 'baz'), + // MAP_REMOVE + map.remove('shouldDelete'), + // COUNTER_INC + counter.increment(10), + operationsAppliedPromise, + ]); + + // create a new client and check it syncs with the aggregated data + const client2 = RealtimeWithLiveObjects(helper, clientOptions); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + const channel2 = client2.channels.get(channelName, channelOptionsWithObjectModes()); + + await channel2.attach(); + const pathObject2 = await channel2.object.get(); + const entryInstance2 = pathObject2.instance(); + + expect(entryInstance2.get('counter'), 'Check counter exists').to.exist; + expect(entryInstance2.get('counter').value()).to.equal(11, 'Check counter has correct value'); + + 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(entryInstance2.get('map').get('anotherKey').value()).to.equal( + 'baz', + 'Check map has correct value for "anotherKey" key', + ); + expect(entryInstance2.get('map').get('shouldDelete'), 'Check map does not have "shouldDelete" key').to.not + .exist; + }, client2); + }, + }, + + { + description: 'OBJECT_SYNC sequence does not change references to existing objects', + action: async (ctx) => { + const { helper, channel, entryInstance } = ctx; + + const objectsCreatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), + ]); + await Promise.all([ + entryInstance.set('map', LiveMap.create()), + entryInstance.set('counter', LiveCounter.create()), + objectsCreatedPromise, + ]); + const map = entryInstance.get('map'); + const counter = entryInstance.get('counter'); + + await channel.detach(); + + // wait for the actual OBJECT_SYNC message to confirm it was received and processed + const objectSyncPromise = waitForObjectSync(helper, channel.client); + await channel.attach(); + await objectSyncPromise; + + const newEntryPathObject = await channel.object.get(); + const newEntryInstance = newEntryPathObject.instance(); + const newMapRef = newEntryInstance.get('map'); + const newCounterRef = newEntryInstance.get('counter'); + + 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', + ); + }, + }, + + { + allTransportsAndProtocols: true, + description: 'LiveCounter is initialized with initial value from OBJECT_SYNC sequence', + action: async (ctx) => { + const { client } = ctx; + + await waitFixtureChannelIsReady(client); + + const channel = client.channels.get(liveobjectsFixturesChannel, channelOptionsWithObjectModes()); + + await channel.attach(); + const entryPathObject = await channel.object.get(); + + const counters = [ + { key: 'emptyCounter', value: 0 }, + { key: 'initialValueCounter', value: 10 }, + { key: 'referencedCounter', value: 20 }, + ]; + + counters.forEach((x) => { + const counter = entryPathObject.get(x.key); + expect(counter.value()).to.equal(x.value, `Check counter at key="${x.key}" in root has correct value`); + }); + }, + }, + + { + allTransportsAndProtocols: true, + description: 'LiveMap is initialized with initial value from OBJECT_SYNC sequence', + action: async (ctx) => { + const { helper, client } = ctx; + + await waitFixtureChannelIsReady(client); + + const channel = client.channels.get(liveobjectsFixturesChannel, channelOptionsWithObjectModes()); + + await channel.attach(); + const entryPathObject = await channel.object.get(); + + const emptyMap = entryPathObject.get('emptyMap'); + expect(emptyMap.size()).to.equal(0, 'Check empty map in root has no keys'); + + 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 = entryPathObject.get('valuesMap'); + expect(valuesMap.size()).to.equal(13, 'Check values map in root has correct number of keys'); + + 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').value(), + BufferUtils.base64Decode('eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9'), + ), + 'Check values map has correct bytes value key', + ).to.be.true; + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); + expect( + BufferUtils.areBuffersEqual(valuesMap.get('emptyBytesKey').value(), BufferUtils.base64Decode('')), + 'Check values map has correct empty bytes value key', + ).to.be.true; + expect(valuesMap.get('maxSafeIntegerKey').value()).to.equal( + Number.MAX_SAFE_INTEGER, + 'Check values map has correct maxSafeIntegerKey value', + ); + expect(valuesMap.get('negativeMaxSafeIntegerKey').value()).to.equal( + -Number.MAX_SAFE_INTEGER, + 'Check values map has correct negativeMaxSafeIntegerKey value', + ); + 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').value()).to.deep.equal( + ['foo', 'bar', 'baz'], + `Check values map has correct arrayKey value`, + ); + + const mapFromValuesMap = valuesMap.get('mapKey'); + expect(mapFromValuesMap.size()).to.equal(1, 'Check nested map has correct number of keys'); + }, + }, + + { + description: 'OBJECT_SYNC sequence with "tombstone=true" for an object creates tombstoned object', + action: async (ctx) => { + const { entryInstance, objectsHelper, channel } = ctx; + + const mapId = objectsHelper.fakeMapObjectId(); + const counterId = objectsHelper.fakeCounterObjectId(); + await objectsHelper.processObjectStateMessageOnChannel({ + channel, + syncSerial: 'serial:', // empty serial so sync sequence ends immediately + // add object states with tombstone=true + state: [ + objectsHelper.mapObject({ + objectId: mapId, + siteTimeserials: { + aaa: lexicoTimeserial('aaa', 0, 0), + }, + tombstone: true, + initialEntries: {}, + }), + objectsHelper.counterObject({ + objectId: counterId, + siteTimeserials: { + aaa: lexicoTimeserial('aaa', 0, 0), + }, + tombstone: true, + initialCount: 1, + }), + objectsHelper.mapObject({ + objectId: 'root', + siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, + initialEntries: { + map: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: mapId } }, + counter: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: counterId } }, + foo: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { string: 'bar' } }, + }, + }), + ], + }); + + expect( + entryInstance.get('map'), + 'Check map does not exist on root after OBJECT_SYNC with "tombstone=true" for a map object', + ).to.not.exist; + expect( + 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(entryInstance.get('foo'), 'Check property exists on root after OBJECT_SYNC').to.exist; + }, + }, + + { + allTransportsAndProtocols: true, + description: 'OBJECT_SYNC sequence with "tombstone=true" for an object deletes existing object', + action: async (ctx) => { + const { objectsHelper, channelName, channel, entryInstance } = ctx; + + const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); + const { objectId: counterId } = await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'counter', + createOp: objectsHelper.counterCreateRestOp({ number: 1 }), + }); + await counterCreatedPromise; + + expect( + entryInstance.get('counter'), + 'Check counter exists on root before OBJECT_SYNC sequence with "tombstone=true"', + ).to.exist; + + // inject an OBJECT_SYNC message where a counter is now tombstoned + await objectsHelper.processObjectStateMessageOnChannel({ + channel, + syncSerial: 'serial:', // empty serial so sync sequence ends immediately + state: [ + objectsHelper.counterObject({ + objectId: counterId, + siteTimeserials: { + aaa: lexicoTimeserial('aaa', 0, 0), + }, + tombstone: true, + initialCount: 1, + }), + objectsHelper.mapObject({ + objectId: 'root', + siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, + initialEntries: { + counter: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: counterId } }, + foo: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { string: 'bar' } }, + }, + }), + ], + }); + + expect( + 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(entryInstance.get('foo'), 'Check property exists on root after OBJECT_SYNC').to.exist; + }, + }, + + { + allTransportsAndProtocols: true, + description: + 'OBJECT_SYNC sequence with "tombstone=true" for an object triggers subscription callback for existing object', + action: async (ctx) => { + const { objectsHelper, channelName, channel, entryInstance } = ctx; + + const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); + const { objectId: counterId } = await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'counter', + createOp: objectsHelper.counterCreateRestOp({ number: 1 }), + }); + await counterCreatedPromise; + + const counter = entryInstance.get('counter'); + + const counterSubPromise = new Promise((resolve, reject) => + counter.subscribe((event) => { + try { + expect(event.object).to.equal( + counter, + 'Check counter subscription callback is called with the correct object', + ); + resolve(); + } catch (error) { + reject(error); + } + }), + ); + + // inject an OBJECT_SYNC message where counter is now tombstoned + await objectsHelper.processObjectStateMessageOnChannel({ + channel, + syncSerial: 'serial:', // empty serial so sync sequence ends immediately + state: [ + objectsHelper.counterObject({ + objectId: counterId, + siteTimeserials: { + aaa: lexicoTimeserial('aaa', 0, 0), + }, + tombstone: true, + initialCount: 1, + }), + objectsHelper.mapObject({ + objectId: 'root', + siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, + initialEntries: { + counter: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: counterId } }, + }, + }), + ], + }); + + await counterSubPromise; + }, + }, + + { + description: + 'OBJECT_SYNC sequence with "tombstone=true" for an object sets "tombstoneAt" from "serialTimestamp"', + action: async (ctx) => { + const { helper, objectsHelper, channel, realtimeObject } = ctx; + + const counterId = objectsHelper.fakeCounterObjectId(); + const serialTimestamp = 1234567890; + await objectsHelper.processObjectStateMessageOnChannel({ + channel, + syncSerial: 'serial:', // empty serial so sync sequence ends immediately + serialTimestamp, + state: [ + objectsHelper.counterObject({ + objectId: counterId, + siteTimeserials: { + aaa: lexicoTimeserial('aaa', 0, 0), + }, + tombstone: true, + initialCount: 1, + }), + objectsHelper.mapObject({ + objectId: 'root', + siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, + initialEntries: { + counter: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: 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( + serialTimestamp, + `Check object's "tombstonedAt" value is set to "serialTimestamp" from OBJECT_SYNC sequence`, + ); + }, + }, + + { + 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, realtimeObject } = ctx; + + const tsBeforeMsg = Date.now(); + const counterId = objectsHelper.fakeCounterObjectId(); + await objectsHelper.processObjectStateMessageOnChannel({ + // don't provide serialTimestamp + channel, + syncSerial: 'serial:', // empty serial so sync sequence ends immediately + state: [ + objectsHelper.counterObject({ + objectId: counterId, + siteTimeserials: { + aaa: lexicoTimeserial('aaa', 0, 0), + }, + tombstone: true, + initialCount: 1, + }), + objectsHelper.mapObject({ + objectId: 'root', + siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, + initialEntries: { + counter: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: counterId } }, + }, + }), + ], + }); + const tsAfterMsg = Date.now(); + + 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( + tsBeforeMsg <= obj.tombstonedAt() <= tsAfterMsg, + `Check object's "tombstonedAt" value is set using local clock if no "serialTimestamp" provided`, + ).to.be.true; + }, + }, + + { + description: + 'OBJECT_SYNC sequence with "tombstone=true" for a map entry sets "tombstoneAt" from "serialTimestamp"', + action: async (ctx) => { + const { helper, entryInstance, objectsHelper, channel } = ctx; + + const serialTimestamp = 1234567890; + await objectsHelper.processObjectStateMessageOnChannel({ + channel, + syncSerial: 'serial:', // empty serial so sync sequence ends immediately + state: [ + objectsHelper.mapObject({ + objectId: 'root', + siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, + initialEntries: { + foo: { + timeserial: lexicoTimeserial('aaa', 0, 0), + data: { string: 'bar' }, + tombstone: true, + serialTimestamp, + }, + }, + }), + ], + }); + + helper.recordPrivateApi('read.DefaultInstance._value'); + helper.recordPrivateApi('read.LiveMap._dataRef.data'); + 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', + ).to.exist; + expect(mapEntry.tombstonedAt).to.equal( + serialTimestamp, + `Check map entry's "tombstonedAt" value is set to "serialTimestamp" from OBJECT_SYNC sequence`, + ); + }, + }, + + { + description: + 'OBJECT_SYNC sequence with "tombstone=true" for a map entry sets "tombstoneAt" using local clock if missing "serialTimestamp"', + action: async (ctx) => { + const { helper, entryInstance, objectsHelper, channel } = ctx; + + const tsBeforeMsg = Date.now(); + await objectsHelper.processObjectStateMessageOnChannel({ + channel, + syncSerial: 'serial:', // empty serial so sync sequence ends immediately + state: [ + objectsHelper.mapObject({ + objectId: 'root', + siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, + initialEntries: { + foo: { + timeserial: lexicoTimeserial('aaa', 0, 0), + data: { string: 'bar' }, + tombstone: true, + // don't provide serialTimestamp + }, + }, + }), + ], + }); + const tsAfterMsg = Date.now(); + + helper.recordPrivateApi('read.DefaultInstance._value'); + helper.recordPrivateApi('read.LiveMap._dataRef.data'); + 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', + ).to.exist; + expect( + tsBeforeMsg <= mapEntry.tombstonedAt <= tsAfterMsg, + `Check map entry's "tombstonedAt" value is set using local clock if no "serialTimestamp" provided`, + ).to.be.true; + }, + }, + ]; + + const applyOperationsScenarios = [ + { + allTransportsAndProtocols: true, + description: 'can apply MAP_CREATE with primitives object operation messages', + action: async (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. + // however, in this test we put heavy focus on the data that is being created as the result of the MAP_CREATE op. + + // check no maps exist on root + primitiveMapsFixtures.forEach((fixture) => { + const key = fixture.name; + expect(entryInstance.get(key), `Check "${key}" key doesn't exist on root before applying MAP_CREATE ops`) + .to.not.exist; + }); + + const mapsCreatedPromise = Promise.all( + primitiveMapsFixtures.map((x) => waitForMapKeyUpdate(entryInstance, x.name)), + ); + // create new maps and set on root + await Promise.all( + primitiveMapsFixtures.map((fixture) => + objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: fixture.name, + createOp: objectsHelper.mapCreateRestOp({ data: fixture.restData }), + }), + ), + ); + await mapsCreatedPromise; + + // check created maps + primitiveMapsFixtures.forEach((fixture) => { + const mapKey = fixture.name; + const map = entryInstance.get(mapKey); + + // check all maps exist on root + 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(map.size()).to.equal( + Object.keys(fixture.entries ?? {}).length, + `Check map "${mapKey}" has correct number of keys`, + ); + + Object.entries(fixture.entries ?? {}).forEach(([key, keyData]) => { + checkKeyDataOnInstance({ + helper, + key, + keyData, + instance: map, + msg: `Check map "${mapKey}" has correct value for "${key}" key`, + }); + }); + }); + }, + }, + + { + allTransportsAndProtocols: true, + description: 'can apply MAP_CREATE with object ids object operation messages', + action: async (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 + // 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. + // however, in this test we put heavy focus on the data that is being created as the result of the MAP_CREATE op. + + // check map does not exist on root + expect( + entryInstance.get(withReferencesMapKey), + `Check "${withReferencesMapKey}" key doesn't exist on root before applying MAP_CREATE ops`, + ).to.not.exist; + + 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, + objectsHelper.mapCreateRestOp({ data: { stringKey: { string: 'stringValue' } } }), + ); + const { objectId: referencedCounterObjectId } = await objectsHelper.operationRequest( + channelName, + objectsHelper.counterCreateRestOp({ number: 1 }), + ); + await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: withReferencesMapKey, + createOp: objectsHelper.mapCreateRestOp({ + data: { + mapReference: { objectId: referencedMapObjectId }, + counterReference: { objectId: referencedCounterObjectId }, + }, + }), + }); + await mapCreatedPromise; + + // check map with references exist on root + const withReferencesMap = entryInstance.get(withReferencesMapKey); + expect(withReferencesMap, `Check map at "${withReferencesMapKey}" key in root exists`).to.exist; + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf( + withReferencesMap._value, + 'LiveMap', + `Check map at "${withReferencesMapKey}" key in root is of type LiveMap`, + ); + + // check map with references has correct values + expect(withReferencesMap.size()).to.equal( + 2, + `Check map "${withReferencesMapKey}" has correct number of keys`, + ); + + const referencedCounter = withReferencesMap.get('counterReference'); + const referencedMap = withReferencesMap.get('mapReference'); + + expect(referencedCounter, `Check counter at "counterReference" exists`).to.exist; + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf( + 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; + 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').value()).to.equal( + 'stringValue', + 'Check map at "mapReference" key has correct "stringKey" value', + ); + }, + }, + + { + description: + 'MAP_CREATE object operation messages are applied based on the site timeserials vector of the object', + action: async (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 = [ + objectsHelper.fakeMapObjectId(), + objectsHelper.fakeMapObjectId(), + objectsHelper.fakeMapObjectId(), + objectsHelper.fakeMapObjectId(), + objectsHelper.fakeMapObjectId(), + ]; + await Promise.all( + mapIds.map(async (mapId, i) => { + // send a MAP_SET op first to create a zero-value map with forged site timeserials vector (from the op), and set it on a root. + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('bbb', 1, 0), + siteCode: 'bbb', + state: [objectsHelper.mapSetOp({ objectId: mapId, key: 'foo', data: { string: 'bar' } })], + }); + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', i, 0), + siteCode: 'aaa', + state: [objectsHelper.mapSetOp({ objectId: 'root', key: mapId, data: { objectId: mapId } })], + }); + }), + ); + + // inject operations with various timeserial values + for (const [i, { serial, siteCode }] of [ + { serial: lexicoTimeserial('bbb', 0, 0), siteCode: 'bbb' }, // existing site, earlier CGO, not applied + { serial: lexicoTimeserial('bbb', 1, 0), siteCode: 'bbb' }, // existing site, same CGO, not applied + { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // existing site, later CGO, applied + { serial: lexicoTimeserial('aaa', 0, 0), siteCode: 'aaa' }, // different site, earlier CGO, applied + { serial: lexicoTimeserial('ccc', 9, 0), siteCode: 'ccc' }, // different site, later CGO, applied + ].entries()) { + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial, + siteCode, + state: [ + objectsHelper.mapCreateOp({ + objectId: mapIds[i], + entries: { + baz: { timeserial: serial, data: { string: 'qux' } }, + }, + }), + ], + }); + } + + // check only operations with correct timeserials were applied + const expectedMapValues = [ + { foo: 'bar' }, + { foo: 'bar' }, + { foo: 'bar', baz: 'qux' }, // applied MAP_CREATE + { foo: 'bar', baz: 'qux' }, // applied MAP_CREATE + { foo: 'bar', baz: 'qux' }, // applied MAP_CREATE + ]; + + for (const [i, mapId] of mapIds.entries()) { + const expectedMapValue = expectedMapValues[i]; + const expectedKeysCount = Object.keys(expectedMapValue).length; + + 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(entryInstance.get(mapId).get(key).value()).to.equal( + value, + `Check map #${i + 1} has expected value for "${key}" key after MAP_CREATE ops`, + ); + }); + } + }, + }, + + { + allTransportsAndProtocols: true, + description: 'can apply MAP_SET with primitives object operation messages', + action: async (ctx) => { + const { objectsHelper, channelName, helper, entryInstance } = ctx; + + // check root is empty before ops + primitiveKeyData.forEach((keyData) => { + expect( + entryInstance.get(keyData.key), + `Check "${keyData.key}" key doesn't exist on root before applying MAP_SET ops`, + ).to.not.exist; + }); + + const keysUpdatedPromise = Promise.all( + primitiveKeyData.map((x) => waitForMapKeyUpdate(entryInstance, x.key)), + ); + // apply MAP_SET ops + await Promise.all( + primitiveKeyData.map((keyData) => + objectsHelper.operationRequest( + channelName, + objectsHelper.mapSetRestOp({ + objectId: 'root', + key: keyData.key, + value: keyData.data, + }), + ), + ), + ); + await keysUpdatedPromise; + + // check everything is applied correctly + primitiveKeyData.forEach((keyData) => { + checkKeyDataOnInstance({ + helper, + key: keyData.key, + keyData, + instance: entryInstance, + msg: `Check root has correct value for "${keyData.key}" key after MAP_SET op`, + }); + }); + }, + }, + + { + allTransportsAndProtocols: true, + description: 'can apply MAP_SET with object ids object operation messages', + action: async (ctx) => { + const { objectsHelper, channelName, entryInstance, helper } = ctx; + + // check no object ids are set on root + expect( + entryInstance.get('keyToCounter'), + `Check "keyToCounter" 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'), + waitForMapKeyUpdate(entryInstance, 'keyToMap'), + ]); + // create new objects and set on root + await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'keyToCounter', + createOp: objectsHelper.counterCreateRestOp({ number: 1 }), + }); + + await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'keyToMap', + createOp: objectsHelper.mapCreateRestOp({ + data: { + stringKey: { string: 'stringValue' }, + }, + }), + }); + await objectsCreatedPromise; + + // check root has refs to new objects and they are not zero-value + 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._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; + 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').value()).to.equal( + 'stringValue', + 'Check map at "keyToMap" key in root has correct "stringKey" value', + ); + }, + }, + + { + description: + 'MAP_SET object operation messages are applied based on the site timeserials vector of the object', + action: async (ctx) => { + const { entryInstance, objectsHelper, channel } = ctx; + + // create new map and set it on a root with forged timeserials + const mapId = objectsHelper.fakeMapObjectId(); + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('bbb', 1, 0), + siteCode: 'bbb', + state: [ + objectsHelper.mapCreateOp({ + objectId: mapId, + entries: { + foo1: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, + foo2: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, + foo3: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, + foo4: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, + foo5: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, + foo6: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, + }, + }), + ], + }); + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 0, 0), + siteCode: 'aaa', + state: [objectsHelper.mapSetOp({ objectId: 'root', key: 'map', data: { objectId: mapId } })], + }); + + // inject operations with various timeserial values + for (const [i, { serial, siteCode }] of [ + { serial: lexicoTimeserial('bbb', 0, 0), siteCode: 'bbb' }, // existing site, earlier site CGO, not applied + { serial: lexicoTimeserial('bbb', 1, 0), siteCode: 'bbb' }, // existing site, same site CGO, not applied + { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // existing site, later site CGO, applied, site timeserials updated + { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // existing site, same site CGO (updated from last op), not applied + { serial: lexicoTimeserial('aaa', 0, 0), siteCode: 'aaa' }, // different site, earlier entry CGO, not applied + { serial: lexicoTimeserial('ccc', 9, 0), siteCode: 'ccc' }, // different site, later entry CGO, applied + ].entries()) { + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial, + siteCode, + state: [objectsHelper.mapSetOp({ objectId: mapId, key: `foo${i + 1}`, data: { string: 'baz' } })], + }); + } + + // check only operations with correct timeserials were applied + const expectedMapKeys = [ + { key: 'foo1', value: 'bar' }, + { key: 'foo2', value: 'bar' }, + { key: 'foo3', value: 'baz' }, // updated + { key: 'foo4', value: 'bar' }, + { key: 'foo5', value: 'bar' }, + { key: 'foo6', value: 'baz' }, // updated + ]; + + expectedMapKeys.forEach(({ key, value }) => { + expect(entryInstance.get('map').get(key).value()).to.equal( + value, + `Check "${key}" key on map has expected value after MAP_SET ops`, + ); + }); + }, + }, + + { + allTransportsAndProtocols: true, + description: 'can apply MAP_REMOVE object operation messages', + action: async (ctx) => { + const { objectsHelper, channelName, entryInstance } = ctx; + const mapKey = 'map'; + + const mapCreatedPromise = waitForMapKeyUpdate(entryInstance, mapKey); + // create new map and set on root + const { objectId: mapObjectId } = await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: mapKey, + createOp: objectsHelper.mapCreateRestOp({ + data: { + shouldStay: { string: 'foo' }, + shouldDelete: { string: 'bar' }, + }, + }), + }); + await mapCreatedPromise; + + 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').value()).to.equal( + 'foo', + `Check map at "${mapKey}" key in root has correct "shouldStay" value before MAP_REMOVE`, + ); + expect(map.get('shouldDelete').value()).to.equal( + 'bar', + `Check map at "${mapKey}" key in root has correct "shouldDelete" value before MAP_REMOVE`, + ); + + const keyRemovedPromise = waitForMapKeyUpdate(map, 'shouldDelete'); + // send MAP_REMOVE op + await objectsHelper.operationRequest( + channelName, + objectsHelper.mapRemoveRestOp({ + objectId: mapObjectId, + key: 'shouldDelete', + }), + ); + await keyRemovedPromise; + + // check map has correct keys after MAP_REMOVE ops + expect(map.size()).to.equal( + 1, + `Check map at "${mapKey}" key in root has correct number of keys after MAP_REMOVE`, + ); + expect(map.get('shouldStay').value()).to.equal( + 'foo', + `Check map at "${mapKey}" key in root has correct "shouldStay" value after MAP_REMOVE`, + ); + expect( + map.get('shouldDelete'), + `Check map at "${mapKey}" key in root has no "shouldDelete" key after MAP_REMOVE`, + ).to.not.exist; + }, + }, + + { + description: + 'MAP_REMOVE object operation messages are applied based on the site timeserials vector of the object', + action: async (ctx) => { + const { entryInstance, objectsHelper, channel } = ctx; + + // create new map and set it on a root with forged timeserials + const mapId = objectsHelper.fakeMapObjectId(); + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('bbb', 1, 0), + siteCode: 'bbb', + state: [ + objectsHelper.mapCreateOp({ + objectId: mapId, + entries: { + foo1: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, + foo2: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, + foo3: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, + foo4: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, + foo5: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, + foo6: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, + }, + }), + ], + }); + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 0, 0), + siteCode: 'aaa', + state: [objectsHelper.mapSetOp({ objectId: 'root', key: 'map', data: { objectId: mapId } })], + }); + + // inject operations with various timeserial values + for (const [i, { serial, siteCode }] of [ + { serial: lexicoTimeserial('bbb', 0, 0), siteCode: 'bbb' }, // existing site, earlier site CGO, not applied + { serial: lexicoTimeserial('bbb', 1, 0), siteCode: 'bbb' }, // existing site, same site CGO, not applied + { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // existing site, later site CGO, applied, site timeserials updated + { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // existing site, same site CGO (updated from last op), not applied + { serial: lexicoTimeserial('aaa', 0, 0), siteCode: 'aaa' }, // different site, earlier entry CGO, not applied + { serial: lexicoTimeserial('ccc', 9, 0), siteCode: 'ccc' }, // different site, later entry CGO, applied + ].entries()) { + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial, + siteCode, + state: [objectsHelper.mapRemoveOp({ objectId: mapId, key: `foo${i + 1}` })], + }); + } + + // check only operations with correct timeserials were applied + const expectedMapKeys = [ + { key: 'foo1', exists: true }, + { key: 'foo2', exists: true }, + { key: 'foo3', exists: false }, // removed + { key: 'foo4', exists: true }, + { key: 'foo5', exists: true }, + { key: 'foo6', exists: false }, // removed + ]; + + expectedMapKeys.forEach(({ key, exists }) => { + if (exists) { + expect(entryInstance.get('map').get(key), `Check "${key}" key on map still exists after MAP_REMOVE ops`) + .to.exist; + } else { + expect( + entryInstance.get('map').get(key), + `Check "${key}" key on map does not exist after MAP_REMOVE ops`, + ).to.not.exist; + } + }); + }, + }, + + { + description: 'MAP_REMOVE for a map entry sets "tombstoneAt" from "serialTimestamp"', + action: async (ctx) => { + const { helper, channel, entryInstance, objectsHelper } = ctx; + + const serialTimestamp = 1234567890; + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 0, 0), + serialTimestamp, + siteCode: 'aaa', + state: [objectsHelper.mapRemoveOp({ objectId: 'root', key: 'foo' })], + }); + + helper.recordPrivateApi('read.DefaultInstance._value'); + helper.recordPrivateApi('read.LiveMap._dataRef.data'); + 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( + serialTimestamp, + `Check map entry's "tombstonedAt" value is set to "serialTimestamp" from MAP_REMOVE`, + ); + }, + }, + + { + description: 'MAP_REMOVE for a map entry sets "tombstoneAt" using local clock if missing "serialTimestamp"', + action: async (ctx) => { + const { helper, channel, entryInstance, objectsHelper } = ctx; + + const tsBeforeMsg = Date.now(); + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 0, 0), + // don't provide serialTimestamp + siteCode: 'aaa', + state: [objectsHelper.mapRemoveOp({ objectId: 'root', key: 'foo' })], + }); + const tsAfterMsg = Date.now(); + + helper.recordPrivateApi('read.DefaultInstance._value'); + helper.recordPrivateApi('read.LiveMap._dataRef.data'); + 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( + tsBeforeMsg <= mapEntry.tombstonedAt <= tsAfterMsg, + `Check map entry's "tombstonedAt" value is set using local clock if no "serialTimestamp" provided`, + ).to.be.true; + }, + }, + + { + allTransportsAndProtocols: true, + description: 'can apply COUNTER_CREATE object operation messages', + action: async (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. + // however, in this test we put heavy focus on the data that is being created as the result of the COUNTER_CREATE op. + + // check no counters exist on root + countersFixtures.forEach((fixture) => { + const key = fixture.name; + expect( + entryInstance.get(key), + `Check "${key}" key doesn't exist on root before applying COUNTER_CREATE ops`, + ).to.not.exist; + }); + + const countersCreatedPromise = Promise.all( + countersFixtures.map((x) => waitForMapKeyUpdate(entryInstance, x.name)), + ); + // create new counters and set on root + await Promise.all( + countersFixtures.map((fixture) => + objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: fixture.name, + createOp: objectsHelper.counterCreateRestOp({ number: fixture.count }), + }), + ), + ); + await countersCreatedPromise; + + // check created counters + countersFixtures.forEach((fixture) => { + const key = fixture.name; + const counter = entryInstance.get(key); + + // check all counters exist on root + expect(counter, `Check counter at "${key}" key in root exists`).to.exist; + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf( + counter._value, + 'LiveCounter', + `Check counter at "${key}" key in root is of type LiveCounter`, + ); + + // check counters have correct values + 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`, + ); + }); + }, + }, + + { + description: + 'COUNTER_CREATE object operation messages are applied based on the site timeserials vector of the object', + action: async (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 = [ + objectsHelper.fakeCounterObjectId(), + objectsHelper.fakeCounterObjectId(), + objectsHelper.fakeCounterObjectId(), + objectsHelper.fakeCounterObjectId(), + objectsHelper.fakeCounterObjectId(), + ]; + await Promise.all( + counterIds.map(async (counterId, i) => { + // send a COUNTER_INC op first to create a zero-value counter with forged site timeserials vector (from the op), and set it on a root. + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('bbb', 1, 0), + siteCode: 'bbb', + state: [objectsHelper.counterIncOp({ objectId: counterId, amount: 1 })], + }); + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', i, 0), + siteCode: 'aaa', + state: [objectsHelper.mapSetOp({ objectId: 'root', key: counterId, data: { objectId: counterId } })], + }); + }), + ); + + // inject operations with various timeserial values + for (const [i, { serial, siteCode }] of [ + { serial: lexicoTimeserial('bbb', 0, 0), siteCode: 'bbb' }, // existing site, earlier CGO, not applied + { serial: lexicoTimeserial('bbb', 1, 0), siteCode: 'bbb' }, // existing site, same CGO, not applied + { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // existing site, later CGO, applied + { serial: lexicoTimeserial('aaa', 0, 0), siteCode: 'aaa' }, // different site, earlier CGO, applied + { serial: lexicoTimeserial('ccc', 9, 0), siteCode: 'ccc' }, // different site, later CGO, applied + ].entries()) { + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial, + siteCode, + state: [objectsHelper.counterCreateOp({ objectId: counterIds[i], count: 10 })], + }); + } + + // check only operations with correct timeserials were applied + const expectedCounterValues = [ + 1, + 1, + 11, // applied COUNTER_CREATE + 11, // applied COUNTER_CREATE + 11, // applied COUNTER_CREATE + ]; + + for (const [i, counterId] of counterIds.entries()) { + const expectedValue = expectedCounterValues[i]; + + expect(entryInstance.get(counterId).value()).to.equal( + expectedValue, + `Check counter #${i + 1} has expected value after COUNTER_CREATE ops`, + ); + } + }, + }, + + { + allTransportsAndProtocols: true, + description: 'can apply COUNTER_INC object operation messages', + action: async (ctx) => { + const { objectsHelper, channelName, entryInstance } = ctx; + const counterKey = 'counter'; + let expectedCounterValue = 0; + + const counterCreated = waitForMapKeyUpdate(entryInstance, counterKey); + // create new counter and set on root + const { objectId: counterObjectId } = await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: counterKey, + createOp: objectsHelper.counterCreateRestOp({ number: expectedCounterValue }), + }); + await counterCreated; + + const counter = entryInstance.get(counterKey); + // check counter has expected value before COUNTER_INC + expect(counter.value()).to.equal( + expectedCounterValue, + `Check counter at "${counterKey}" key in root has correct value before COUNTER_INC`, + ); + + const increments = [ + 1, // value=1 + 10, // value=11 + 100, // value=111 + 1000000, // value=1000111 + -1000111, // value=0 + -1, // value=-1 + -10, // value=-11 + -100, // value=-111 + -1000000, // value=-1000111 + 1000111, // value=0 + Number.MAX_SAFE_INTEGER, // value=9007199254740991 + // do next decrements in 2 steps as opposed to multiplying by -2 to prevent overflow + -Number.MAX_SAFE_INTEGER, // value=0 + -Number.MAX_SAFE_INTEGER, // value=-9007199254740991 + ]; + + // send increments one at a time and check expected value + for (let i = 0; i < increments.length; i++) { + const increment = increments[i]; + expectedCounterValue += increment; + + const counterUpdatedPromise = waitForCounterUpdate(counter); + await objectsHelper.operationRequest( + channelName, + objectsHelper.counterIncRestOp({ + objectId: counterObjectId, + number: increment, + }), + ); + await counterUpdatedPromise; + + expect(counter.value()).to.equal( + expectedCounterValue, + `Check counter at "${counterKey}" key in root has correct value after ${i + 1} COUNTER_INC ops`, + ); + } + }, + }, + + { + description: + 'COUNTER_INC object operation messages are applied based on the site timeserials vector of the object', + action: async (ctx) => { + const { entryInstance, objectsHelper, channel } = ctx; + + // create new counter and set it on a root with forged timeserials + const counterId = objectsHelper.fakeCounterObjectId(); + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('bbb', 1, 0), + siteCode: 'bbb', + state: [objectsHelper.counterCreateOp({ objectId: counterId, count: 1 })], + }); + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 0, 0), + siteCode: 'aaa', + state: [objectsHelper.mapSetOp({ objectId: 'root', key: 'counter', data: { objectId: counterId } })], + }); + + // inject operations with various timeserial values + for (const [i, { serial, siteCode }] of [ + { serial: lexicoTimeserial('bbb', 0, 0), siteCode: 'bbb' }, // +10 existing site, earlier CGO, not applied + { serial: lexicoTimeserial('bbb', 1, 0), siteCode: 'bbb' }, // +100 existing site, same CGO, not applied + { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // +1000 existing site, later CGO, applied, site timeserials updated + { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // +10000 existing site, same CGO (updated from last op), not applied + { serial: lexicoTimeserial('aaa', 0, 0), siteCode: 'aaa' }, // +100000 different site, earlier CGO, applied + { serial: lexicoTimeserial('ccc', 9, 0), siteCode: 'ccc' }, // +1000000 different site, later CGO, applied + ].entries()) { + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial, + siteCode, + state: [objectsHelper.counterIncOp({ objectId: counterId, amount: Math.pow(10, i + 1) })], + }); + } + + // check only operations with correct timeserials were applied + 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`, + ); + }, + }, + + { + description: 'can apply OBJECT_DELETE object operation messages', + action: async (ctx) => { + const { objectsHelper, channelName, channel, entryInstance } = ctx; + + const objectsCreatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + ]); + // create initial objects and set on root + const { objectId: mapObjectId } = await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'map', + createOp: objectsHelper.mapCreateRestOp(), + }); + const { objectId: counterObjectId } = await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'counter', + createOp: objectsHelper.counterCreateRestOp(), + }); + await objectsCreatedPromise; + + 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({ + channel, + serial: lexicoTimeserial('aaa', 0, 0), + siteCode: 'aaa', + state: [objectsHelper.objectDeleteOp({ objectId: mapObjectId })], + }); + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 1, 0), + siteCode: 'aaa', + state: [objectsHelper.objectDeleteOp({ objectId: counterObjectId })], + }); + + 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 { entryInstance, objectsHelper, channel } = ctx; + + const counterId = objectsHelper.fakeCounterObjectId(); + // inject OBJECT_DELETE. should create a zero-value tombstoned object which can't be modified + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 0, 0), + siteCode: 'aaa', + state: [objectsHelper.objectDeleteOp({ objectId: counterId })], + }); + + // try to create and set tombstoned object on root + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('bbb', 0, 0), + siteCode: 'bbb', + state: [objectsHelper.counterCreateOp({ objectId: counterId })], + }); + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('bbb', 1, 0), + siteCode: 'bbb', + state: [objectsHelper.mapSetOp({ objectId: 'root', key: 'counter', data: { objectId: counterId } })], + }); + + expect(entryInstance.get('counter'), 'Check counter is not accessible on root').to.not.exist; + }, + }, + + { + description: + 'OBJECT_DELETE object operation messages are applied based on the site timeserials vector of the object', + action: async (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 = [ + objectsHelper.fakeCounterObjectId(), + objectsHelper.fakeCounterObjectId(), + objectsHelper.fakeCounterObjectId(), + objectsHelper.fakeCounterObjectId(), + objectsHelper.fakeCounterObjectId(), + ]; + await Promise.all( + counterIds.map(async (counterId, i) => { + // create objects and set them on root with forged timeserials + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('bbb', 1, 0), + siteCode: 'bbb', + state: [objectsHelper.counterCreateOp({ objectId: counterId })], + }); + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', i, 0), + siteCode: 'aaa', + state: [objectsHelper.mapSetOp({ objectId: 'root', key: counterId, data: { objectId: counterId } })], + }); + }), + ); + + // inject OBJECT_DELETE operations with various timeserial values + for (const [i, { serial, siteCode }] of [ + { serial: lexicoTimeserial('bbb', 0, 0), siteCode: 'bbb' }, // existing site, earlier CGO, not applied + { serial: lexicoTimeserial('bbb', 1, 0), siteCode: 'bbb' }, // existing site, same CGO, not applied + { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // existing site, later CGO, applied + { serial: lexicoTimeserial('aaa', 0, 0), siteCode: 'aaa' }, // different site, earlier CGO, applied + { serial: lexicoTimeserial('ccc', 9, 0), siteCode: 'ccc' }, // different site, later CGO, applied + ].entries()) { + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial, + siteCode, + state: [objectsHelper.objectDeleteOp({ objectId: counterIds[i] })], + }); + } + + // check only operations with correct timeserials were applied + const expectedCounters = [ + { exists: true }, + { exists: true }, + { exists: false }, // OBJECT_DELETE applied + { exists: false }, // OBJECT_DELETE applied + { exists: false }, // OBJECT_DELETE applied + ]; + + for (const [i, counterId] of counterIds.entries()) { + const { exists } = expectedCounters[i]; + + if (exists) { + expect( + entryInstance.get(counterId), + `Check counter #${i + 1} exists on root as OBJECT_DELETE op was not applied`, + ).to.exist; + } else { + expect( + entryInstance.get(counterId), + `Check counter #${i + 1} does not exist on root as OBJECT_DELETE op was applied`, + ).to.not.exist; + } + } + }, + }, + + { + description: 'OBJECT_DELETE triggers subscription callback with deleted data', + action: async (ctx) => { + const { objectsHelper, channelName, channel, entryInstance } = ctx; + + const objectsCreatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + ]); + // create initial objects and set on root + const { objectId: mapObjectId } = await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'map', + createOp: objectsHelper.mapCreateRestOp({ + data: { + foo: { string: 'bar' }, + baz: { number: 1 }, + }, + }), + }); + const { objectId: counterObjectId } = await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'counter', + createOp: objectsHelper.counterCreateRestOp({ number: 1 }), + }); + await objectsCreatedPromise; + + const mapId = entryInstance.get('map').id; + const counterId = entryInstance.get('counter').id; + + const mapSubPromise = new Promise((resolve, reject) => + entryInstance.get('map').subscribe((event) => { + try { + 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) { + reject(error); + } + }), + ); + const counterSubPromise = new Promise((resolve, reject) => + entryInstance.get('counter').subscribe((event) => { + try { + 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) { + reject(error); + } + }), + ); + + // inject OBJECT_DELETE + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 0, 0), + siteCode: 'aaa', + state: [objectsHelper.objectDeleteOp({ objectId: mapObjectId })], + }); + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 1, 0), + siteCode: 'aaa', + state: [objectsHelper.objectDeleteOp({ objectId: counterObjectId })], + }); + + await Promise.all([mapSubPromise, counterSubPromise]); + }, + }, + + { + description: 'OBJECT_DELETE for an object sets "tombstoneAt" from "serialTimestamp"', + action: async (ctx) => { + const { objectsHelper, channelName, channel, helper, realtimeObject, entryInstance } = ctx; + + const objectCreatedPromise = waitForMapKeyUpdate(entryInstance, 'object'); + const { objectId } = await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'object', + createOp: objectsHelper.counterCreateRestOp(), + }); + await objectCreatedPromise; + + expect(entryInstance.get('object'), 'Check object exists on root before OBJECT_DELETE').to.exist; + + // inject OBJECT_DELETE + const serialTimestamp = 1234567890; + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 1, 0), + serialTimestamp, + siteCode: 'aaa', + state: [objectsHelper.objectDeleteOp({ 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'); + expect(obj.tombstonedAt()).to.equal( + serialTimestamp, + `Check object's "tombstonedAt" value is set to "serialTimestamp" from OBJECT_DELETE`, + ); + }, + }, + + { + description: 'OBJECT_DELETE for an object sets "tombstoneAt" using local clock if missing "serialTimestamp"', + action: async (ctx) => { + const { objectsHelper, channelName, channel, helper, realtimeObject, entryInstance } = ctx; + + const objectCreatedPromise = waitForMapKeyUpdate(entryInstance, 'object'); + const { objectId } = await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'object', + createOp: objectsHelper.counterCreateRestOp(), + }); + await objectCreatedPromise; + + expect(entryInstance.get('object'), 'Check object exists on root before OBJECT_DELETE').to.exist; + + const tsBeforeMsg = Date.now(); + // inject OBJECT_DELETE + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 1, 0), + // don't provide serialTimestamp + siteCode: 'aaa', + state: [objectsHelper.objectDeleteOp({ objectId })], + }); + const tsAfterMsg = Date.now(); + + 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'); + expect( + tsBeforeMsg <= obj.tombstonedAt() <= tsAfterMsg, + `Check object's "tombstonedAt" value is set using local clock if no "serialTimestamp" provided`, + ).to.be.true; + }, + }, + + { + description: 'MAP_SET with reference to a tombstoned object results in undefined value on key', + action: async (ctx) => { + const { objectsHelper, channelName, channel, entryInstance } = ctx; + + const objectCreatedPromise = waitForMapKeyUpdate(entryInstance, 'foo'); + // create initial objects and set on root + const { objectId: counterObjectId } = await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'foo', + createOp: objectsHelper.counterCreateRestOp(), + }); + await objectCreatedPromise; + + expect(entryInstance.get('foo'), 'Check counter exists on root before OBJECT_DELETE').to.exist; + + // inject OBJECT_DELETE + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 0, 0), + siteCode: 'aaa', + state: [objectsHelper.objectDeleteOp({ objectId: counterObjectId })], + }); + + // set tombstoned counter to another key on root + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 0, 0), + siteCode: 'aaa', + state: [objectsHelper.mapSetOp({ objectId: 'root', key: 'bar', data: { objectId: counterObjectId } })], + }); + + 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 { objectsHelper, channelName, channel, entryInstance } = ctx; + + const objectsCreatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'map1'), + waitForMapKeyUpdate(entryInstance, 'map2'), + waitForMapKeyUpdate(entryInstance, 'counter1'), + ]); + // create initial objects and set on root + const { objectId: mapId1 } = await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'map1', + createOp: objectsHelper.mapCreateRestOp(), + }); + const { objectId: mapId2 } = await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'map2', + createOp: objectsHelper.mapCreateRestOp({ data: { foo: { string: 'bar' } } }), + }); + const { objectId: counterId1 } = await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'counter1', + createOp: objectsHelper.counterCreateRestOp(), + }); + await objectsCreatedPromise; + + 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({ + channel, + serial: lexicoTimeserial('aaa', 0, 0), + siteCode: 'aaa', + state: [objectsHelper.objectDeleteOp({ objectId: mapId1 })], + }); + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 1, 0), + siteCode: 'aaa', + state: [objectsHelper.objectDeleteOp({ objectId: mapId2 })], + }); + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 2, 0), + siteCode: 'aaa', + state: [objectsHelper.objectDeleteOp({ objectId: counterId1 })], + }); + + // inject object operations on tombstoned objects + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 3, 0), + siteCode: 'aaa', + state: [objectsHelper.mapSetOp({ objectId: mapId1, key: 'baz', data: { string: 'qux' } })], + }); + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 4, 0), + siteCode: 'aaa', + state: [objectsHelper.mapRemoveOp({ objectId: mapId2, key: 'foo' })], + }); + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 5, 0), + siteCode: 'aaa', + state: [objectsHelper.counterIncOp({ objectId: counterId1, amount: 1 })], + }); + + // objects should still be deleted + expect( + 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; + }, + }, + ]; + + const applyOperationsDuringSyncScenarios = [ + { + description: 'object operation messages are buffered during OBJECT_SYNC sequence', + action: async (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({ + channel, + syncSerial: 'serial:cursor', + }); + + // inject operations, it should not be applied as sync is in progress + await Promise.all( + primitiveKeyData.map(async (keyData) => { + // copy data object as library will modify it + const data = { ...keyData.data }; + helper.recordPrivateApi('read.realtime.options.useBinaryProtocol'); + if (data.bytes != null && client.options.useBinaryProtocol) { + // decode base64 data to binary for binary protocol + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + data.bytes = BufferUtils.base64Decode(data.bytes); + } + + return objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', 0, 0), + siteCode: 'aaa', + state: [objectsHelper.mapSetOp({ objectId: 'root', key: keyData.key, data })], + }); + }), + ); + + // check root doesn't have data from operations + primitiveKeyData.forEach((keyData) => { + expect( + entryInstance.get(keyData.key), + `Check "${keyData.key}" key doesn't exist on root during OBJECT_SYNC`, + ).to.not.exist; + }); + }, + }, + + { + description: 'buffered object operation messages are applied when OBJECT_SYNC sequence ends', + action: async (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({ + channel, + syncSerial: 'serial:cursor', + }); + + // inject operations, they should be applied when sync ends + await Promise.all( + primitiveKeyData.map(async (keyData, i) => { + // copy data object as library will modify it + const data = { ...keyData.data }; + helper.recordPrivateApi('read.realtime.options.useBinaryProtocol'); + if (data.bytes != null && client.options.useBinaryProtocol) { + // decode base64 data to binary for binary protocol + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + data.bytes = BufferUtils.base64Decode(data.bytes); + } + + return objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', i, 0), + siteCode: 'aaa', + state: [objectsHelper.mapSetOp({ objectId: 'root', key: keyData.key, data })], + }); + }), + ); + + // end the sync with empty cursor + await objectsHelper.processObjectStateMessageOnChannel({ + channel, + syncSerial: 'serial:', + }); + + // check everything is applied correctly + primitiveKeyData.forEach((keyData) => { + checkKeyDataOnInstance({ + helper, + key: keyData.key, + keyData, + instance: entryInstance, + msg: `Check root has correct value for "${keyData.key}" key after OBJECT_SYNC has ended and buffered operations are applied`, + }); + }); + }, + }, + + { + description: 'buffered object operation messages are discarded when new OBJECT_SYNC sequence starts', + action: async (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({ + channel, + syncSerial: 'serial:cursor', + }); + + // inject operations, expect them to be discarded when sync with new sequence id starts + await Promise.all( + primitiveKeyData.map(async (keyData, i) => { + // copy data object as library will modify it + const data = { ...keyData.data }; + helper.recordPrivateApi('read.realtime.options.useBinaryProtocol'); + if (data.bytes != null && client.options.useBinaryProtocol) { + // decode base64 data to binary for binary protocol + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + data.bytes = BufferUtils.base64Decode(data.bytes); + } + + return objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', i, 0), + siteCode: 'aaa', + state: [objectsHelper.mapSetOp({ objectId: 'root', key: keyData.key, data })], + }); + }), + ); + + // start new sync with new sequence id + await objectsHelper.processObjectStateMessageOnChannel({ + channel, + syncSerial: 'otherserial:cursor', + }); + + // inject another operation that should be applied when latest sync ends + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('bbb', 0, 0), + siteCode: 'bbb', + state: [objectsHelper.mapSetOp({ objectId: 'root', key: 'foo', data: { string: 'bar' } })], + }); + + // end sync + await objectsHelper.processObjectStateMessageOnChannel({ + channel, + syncSerial: 'otherserial:', + }); + + // check root doesn't have data from operations received during first sync + primitiveKeyData.forEach((keyData) => { + expect( + 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(entryInstance.get('foo').value()).to.equal( + 'bar', + 'Check root has data from operations received during second OBJECT_SYNC sequence', + ); + }, + }, + + { + description: + 'buffered object operation messages are applied based on the site timeserials vector of the object', + action: async (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(); + const counterId = objectsHelper.fakeCounterObjectId(); + await objectsHelper.processObjectStateMessageOnChannel({ + channel, + syncSerial: 'serial:cursor', + // add object state messages with non-empty site timeserials + state: [ + // next map and counter objects will be checked to have correct operations applied on them based on site timeserials + objectsHelper.mapObject({ + objectId: mapId, + siteTimeserials: { + bbb: lexicoTimeserial('bbb', 2, 0), + ccc: lexicoTimeserial('ccc', 5, 0), + }, + materialisedEntries: { + foo1: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, + foo2: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, + foo3: { timeserial: lexicoTimeserial('ccc', 5, 0), data: { string: 'bar' } }, + foo4: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, + foo5: { timeserial: lexicoTimeserial('bbb', 2, 0), data: { string: 'bar' } }, + foo6: { timeserial: lexicoTimeserial('ccc', 2, 0), data: { string: 'bar' } }, + foo7: { timeserial: lexicoTimeserial('ccc', 0, 0), data: { string: 'bar' } }, + foo8: { timeserial: lexicoTimeserial('ccc', 0, 0), data: { string: 'bar' } }, + }, + }), + objectsHelper.counterObject({ + objectId: counterId, + siteTimeserials: { + bbb: lexicoTimeserial('bbb', 1, 0), + }, + initialCount: 1, + }), + // add objects to the root so they're discoverable in the object tree + objectsHelper.mapObject({ + objectId: 'root', + siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, + initialEntries: { + map: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: mapId } }, + counter: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: counterId } }, + }, + }), + ], + }); + + // inject operations with various timeserial values + // Map: + for (const [i, { serial, siteCode }] of [ + { serial: lexicoTimeserial('bbb', 1, 0), siteCode: 'bbb' }, // existing site, earlier site CGO, not applied + { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // existing site, same site CGO, not applied + { serial: lexicoTimeserial('bbb', 3, 0), siteCode: 'bbb' }, // existing site, later site CGO, earlier entry CGO, not applied but site timeserial updated + // message with later site CGO, same entry CGO case is not possible, as timeserial from entry would be set for the corresponding site code or be less than that + { serial: lexicoTimeserial('bbb', 3, 0), siteCode: 'bbb' }, // existing site, same site CGO (updated from last op), later entry CGO, not applied + { serial: lexicoTimeserial('bbb', 4, 0), siteCode: 'bbb' }, // existing site, later site CGO, later entry CGO, applied + { serial: lexicoTimeserial('aaa', 1, 0), siteCode: 'aaa' }, // different site, earlier entry CGO, not applied but site timeserial updated + { serial: lexicoTimeserial('aaa', 1, 0), siteCode: 'aaa' }, // different site, same site CGO (updated from last op), later entry CGO, not applied + // different site with matching entry CGO case is not possible, as matching entry timeserial means that that timeserial is in the site timeserials vector + { serial: lexicoTimeserial('ddd', 1, 0), siteCode: 'ddd' }, // different site, later entry CGO, applied + ].entries()) { + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial, + siteCode, + state: [objectsHelper.mapSetOp({ objectId: mapId, key: `foo${i + 1}`, data: { string: 'baz' } })], + }); + } + + // Counter: + for (const [i, { serial, siteCode }] of [ + { serial: lexicoTimeserial('bbb', 0, 0), siteCode: 'bbb' }, // +10 existing site, earlier CGO, not applied + { serial: lexicoTimeserial('bbb', 1, 0), siteCode: 'bbb' }, // +100 existing site, same CGO, not applied + { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // +1000 existing site, later CGO, applied, site timeserials updated + { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // +10000 existing site, same CGO (updated from last op), not applied + { serial: lexicoTimeserial('aaa', 0, 0), siteCode: 'aaa' }, // +100000 different site, earlier CGO, applied + { serial: lexicoTimeserial('ccc', 9, 0), siteCode: 'ccc' }, // +1000000 different site, later CGO, applied + ].entries()) { + await objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial, + siteCode, + state: [objectsHelper.counterIncOp({ objectId: counterId, amount: Math.pow(10, i + 1) })], + }); + } + + // end sync + await objectsHelper.processObjectStateMessageOnChannel({ + channel, + syncSerial: 'serial:', + }); + + // check only operations with correct timeserials were applied + const expectedMapKeys = [ + { key: 'foo1', value: 'bar' }, + { key: 'foo2', value: 'bar' }, + { key: 'foo3', value: 'bar' }, + { key: 'foo4', value: 'bar' }, + { key: 'foo5', value: 'baz' }, // updated + { key: 'foo6', value: 'bar' }, + { key: 'foo7', value: 'bar' }, + { key: 'foo8', value: 'baz' }, // updated + ]; + + expectedMapKeys.forEach(({ key, value }) => { + expect(entryInstance.get('map').get(key).value()).to.equal( + value, + `Check "${key}" key on map has expected value after OBJECT_SYNC has ended`, + ); + }); + + 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`, + ); + }, + }, + + { + description: + 'subsequent object operation messages are applied immediately after OBJECT_SYNC ended and buffers are applied', + action: async (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({ + channel, + syncSerial: 'serial:cursor', + }); + + // inject operations, they should be applied when sync ends + await Promise.all( + primitiveKeyData.map(async (keyData, i) => { + // copy data object as library will modify it + const data = { ...keyData.data }; + helper.recordPrivateApi('read.realtime.options.useBinaryProtocol'); + if (data.bytes != null && client.options.useBinaryProtocol) { + // decode base64 data to binary for binary protocol + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + data.bytes = BufferUtils.base64Decode(data.bytes); + } + + return objectsHelper.processObjectOperationMessageOnChannel({ + channel, + serial: lexicoTimeserial('aaa', i, 0), + siteCode: 'aaa', + state: [objectsHelper.mapSetOp({ objectId: 'root', key: keyData.key, data })], + }); + }), + ); + + // end the sync with empty cursor + await objectsHelper.processObjectStateMessageOnChannel({ + channel, + syncSerial: 'serial:', + }); + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'foo'); + // send some more operations + await objectsHelper.operationRequest( + channelName, + objectsHelper.mapSetRestOp({ + objectId: 'root', + key: 'foo', + value: { string: 'bar' }, + }), + ); + await keyUpdatedPromise; + + // check buffered operations are applied, as well as the most recent operation outside of the sync sequence is applied + primitiveKeyData.forEach((keyData) => { + checkKeyDataOnInstance({ + helper, + key: keyData.key, + keyData, + instance: entryInstance, + msg: `Check root has correct value for "${keyData.key}" key after OBJECT_SYNC has ended and buffered operations are applied`, + }); + }); + 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', + ); + }, + }, + ]; + + const writeApiScenarios = [ + { + allTransportsAndProtocols: true, + description: 'LiveCounter.increment sends COUNTER_INC operation', + action: async (ctx) => { + const { objectsHelper, channelName, entryInstance } = ctx; + + const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); + await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'counter', + createOp: objectsHelper.counterCreateRestOp(), + }); + await counterCreatedPromise; + + const counter = entryInstance.get('counter'); + const increments = [ + 1, // value=1 + 10, // value=11 + -11, // value=0 + -1, // value=-1 + -10, // value=-11 + 11, // value=0 + Number.MAX_SAFE_INTEGER, // value=9007199254740991 + -Number.MAX_SAFE_INTEGER, // value=0 + -Number.MAX_SAFE_INTEGER, // value=-9007199254740991 + ]; + let expectedCounterValue = 0; + + for (let i = 0; i < increments.length; i++) { + const increment = increments[i]; + expectedCounterValue += increment; + + const counterUpdatedPromise = waitForCounterUpdate(counter); + await counter.increment(increment); + await counterUpdatedPromise; + + expect(counter.value()).to.equal( + expectedCounterValue, + `Check counter has correct value after ${i + 1} LiveCounter.increment calls`, + ); + } + }, + }, + + { + description: 'LiveCounter.increment throws on invalid input', + action: async (ctx) => { + const { objectsHelper, channelName, entryInstance } = ctx; + + const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); + await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'counter', + createOp: objectsHelper.counterCreateRestOp(), + }); + await counterCreatedPromise; + + const counter = entryInstance.get('counter'); + + await expectToThrowAsync( + async () => counter.increment(Number.NaN), + 'Counter value increment should be a valid number', + ); + await expectToThrowAsync( + async () => counter.increment(Number.POSITIVE_INFINITY), + 'Counter value increment should be a valid number', + ); + await expectToThrowAsync( + async () => counter.increment(Number.NEGATIVE_INFINITY), + 'Counter value increment should be a valid number', + ); + await expectToThrowAsync( + async () => counter.increment('foo'), + 'Counter value increment should be a valid number', + ); + await expectToThrowAsync( + async () => counter.increment(BigInt(1)), + 'Counter value increment should be a valid number', + ); + await expectToThrowAsync( + async () => counter.increment(true), + 'Counter value increment should be a valid number', + ); + await expectToThrowAsync( + async () => counter.increment(Symbol()), + 'Counter value increment should be a valid number', + ); + await expectToThrowAsync( + async () => counter.increment({}), + 'Counter value increment should be a valid number', + ); + await expectToThrowAsync( + async () => counter.increment([]), + 'Counter value increment should be a valid number', + ); + await expectToThrowAsync( + async () => counter.increment(counter), + 'Counter value increment should be a valid number', + ); + }, + }, + + { + allTransportsAndProtocols: true, + description: 'LiveCounter.decrement sends COUNTER_INC operation', + action: async (ctx) => { + const { objectsHelper, channelName, entryInstance } = ctx; + + const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); + await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'counter', + createOp: objectsHelper.counterCreateRestOp(), + }); + await counterCreatedPromise; + + const counter = entryInstance.get('counter'); + const decrements = [ + 1, // value=-1 + 10, // value=-11 + -11, // value=0 + -1, // value=1 + -10, // value=11 + 11, // value=0 + Number.MAX_SAFE_INTEGER, // value=-9007199254740991 + -Number.MAX_SAFE_INTEGER, // value=0 + -Number.MAX_SAFE_INTEGER, // value=9007199254740991 + ]; + let expectedCounterValue = 0; + + for (let i = 0; i < decrements.length; i++) { + const decrement = decrements[i]; + expectedCounterValue -= decrement; + + const counterUpdatedPromise = waitForCounterUpdate(counter); + await counter.decrement(decrement); + await counterUpdatedPromise; + + expect(counter.value()).to.equal( + expectedCounterValue, + `Check counter has correct value after ${i + 1} LiveCounter.decrement calls`, + ); + } + }, + }, + + { + description: 'LiveCounter.decrement throws on invalid input', + action: async (ctx) => { + const { objectsHelper, channelName, entryInstance } = ctx; + + const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); + await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'counter', + createOp: objectsHelper.counterCreateRestOp(), + }); + await counterCreatedPromise; + + const counter = entryInstance.get('counter'); + + await expectToThrowAsync( + async () => counter.decrement(Number.NaN), + 'Counter value decrement should be a valid number', + ); + await expectToThrowAsync( + async () => counter.decrement(Number.POSITIVE_INFINITY), + 'Counter value decrement should be a valid number', + ); + await expectToThrowAsync( + async () => counter.decrement(Number.NEGATIVE_INFINITY), + 'Counter value decrement should be a valid number', + ); + await expectToThrowAsync( + async () => counter.decrement('foo'), + 'Counter value decrement should be a valid number', + ); + await expectToThrowAsync( + async () => counter.decrement(BigInt(1)), + 'Counter value decrement should be a valid number', + ); + await expectToThrowAsync( + async () => counter.decrement(true), + 'Counter value decrement should be a valid number', + ); + await expectToThrowAsync( + async () => counter.decrement(Symbol()), + 'Counter value decrement should be a valid number', + ); + await expectToThrowAsync( + async () => counter.decrement({}), + 'Counter value decrement should be a valid number', + ); + await expectToThrowAsync( + async () => counter.decrement([]), + 'Counter value decrement should be a valid number', + ); + await expectToThrowAsync( + async () => counter.decrement(counter), + 'Counter value decrement should be a valid number', + ); + }, + }, + + { + allTransportsAndProtocols: true, + description: 'LiveMap.set sends MAP_SET operation with primitive values', + action: async (ctx) => { + const { helper, entryInstance } = 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 entryInstance.set(keyData.key, value); + }), + ); + await keysUpdatedPromise; + + // check everything is applied correctly + primitiveKeyData.forEach((keyData) => { + checkKeyDataOnInstance({ + helper, + key: keyData.key, + keyData, + instance: entryInstance, + msg: `Check root has correct value for "${keyData.key}" key after LiveMap.set call`, + }); + }); + }, + }, + + { + allTransportsAndProtocols: true, + description: 'LiveMap.set sends MAP_SET operation with reference to another LiveObject', + action: async (ctx) => { + const { entryInstance, helper } = ctx; + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), + ]); + await entryInstance.set('counter', LiveCounter.create(1)); + await entryInstance.set('map', LiveMap.create({ foo: 'bar' })); + await keysUpdatedPromise; + + 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 { objectsHelper, channelName, entryInstance } = ctx; + + const mapCreatedPromise = waitForMapKeyUpdate(entryInstance, 'map'); + await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'map', + createOp: objectsHelper.mapCreateRestOp(), + }); + await mapCreatedPromise; + + 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'); + await expectToThrowAsync(async () => map.set(1), 'Map key should be string'); + await expectToThrowAsync(async () => map.set(BigInt(1)), 'Map key should be string'); + await expectToThrowAsync(async () => map.set(true), 'Map key should be string'); + await expectToThrowAsync(async () => map.set(Symbol()), 'Map key should be string'); + await expectToThrowAsync(async () => map.set({}), 'Map key should be string'); + await expectToThrowAsync(async () => map.set([]), 'Map key should be string'); + await expectToThrowAsync(async () => map.set(map), 'Map key should be string'); + + await expectToThrowAsync(async () => map.set('key'), 'Map value data type is unsupported'); + await expectToThrowAsync(async () => map.set('key', null), 'Map value data type is unsupported'); + await expectToThrowAsync(async () => map.set('key', BigInt(1)), 'Map value data type is unsupported'); + await expectToThrowAsync(async () => map.set('key', Symbol()), 'Map value data type is unsupported'); + }, + }, + + { + allTransportsAndProtocols: true, + description: 'LiveMap.remove sends MAP_REMOVE operation', + action: async (ctx) => { + const { objectsHelper, channelName, entryInstance } = ctx; + + const mapCreatedPromise = waitForMapKeyUpdate(entryInstance, 'map'); + await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'map', + createOp: objectsHelper.mapCreateRestOp({ + data: { + foo: { number: 1 }, + bar: { number: 1 }, + baz: { number: 1 }, + }, + }), + }); + await mapCreatedPromise; + + const map = entryInstance.get('map'); + + const keysUpdatedPromise = Promise.all([waitForMapKeyUpdate(map, 'foo'), waitForMapKeyUpdate(map, 'bar')]); + await map.remove('foo'); + await map.remove('bar'); + await keysUpdatedPromise; + + 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').value(), + 'Check non-removed keys are still present on a root after LiveMap.remove call for another keys', + ).to.equal(1); + }, + }, + + { + description: 'LiveMap.remove throws on invalid input', + action: async (ctx) => { + const { objectsHelper, channelName, entryInstance } = ctx; + + const mapCreatedPromise = waitForMapKeyUpdate(entryInstance, 'map'); + await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: 'map', + createOp: objectsHelper.mapCreateRestOp(), + }); + await mapCreatedPromise; + + 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'); + await expectToThrowAsync(async () => map.remove(1), 'Map key should be string'); + await expectToThrowAsync(async () => map.remove(BigInt(1)), 'Map key should be string'); + await expectToThrowAsync(async () => map.remove(true), 'Map key should be string'); + await expectToThrowAsync(async () => map.remove(Symbol()), 'Map key should be string'); + await expectToThrowAsync(async () => map.remove({}), 'Map key should be string'); + await expectToThrowAsync(async () => map.remove([]), 'Map key should be string'); + await expectToThrowAsync(async () => map.remove(map), 'Map key should be string'); + }, + }, + + { + 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: 'value type created with LiveCounter.create() can be assigned to the object tree', + action: async (ctx) => { + const { entryInstance, helper } = ctx; + + const counterCreatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); + await entryInstance.set('counter', LiveCounter.create(1)); + await counterCreatedPromise; + + const counter = entryInstance.get('counter'); + + 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'); + }, + }, + + { + allTransportsAndProtocols: true, + description: 'LiveCounter.create() sends COUNTER_CREATE operation', + action: async (ctx) => { + const { entryInstance, helper } = ctx; + + const objectsCreatedPromise = Promise.all( + countersFixtures.map((x) => waitForMapKeyUpdate(entryInstance, x.name)), + ); + 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 = entryInstance.get(countersFixtures[i].name); + const fixture = countersFixtures[i]; + + expect(counter, `Check counter #${i + 1} exists`).to.exist; + 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`, + ); + } + }, + }, + + { + description: + 'value type created with LiveCounter.create() with an invalid input throws when assigned to the object tree', + action: async (ctx) => { + const { entryInstance } = ctx; + + await expectToThrowAsync( + async () => entryInstance.set('counter', LiveCounter.create(null)), + 'Counter value should be a valid number', + ); + await expectToThrowAsync( + async () => entryInstance.set('counter', LiveCounter.create(Number.NaN)), + 'Counter value should be a valid number', + ); + await expectToThrowAsync( + async () => entryInstance.set('counter', LiveCounter.create(Number.POSITIVE_INFINITY)), + 'Counter value should be a valid number', + ); + await expectToThrowAsync( + async () => entryInstance.set('counter', LiveCounter.create(Number.NEGATIVE_INFINITY)), + 'Counter value should be a valid number', + ); + await expectToThrowAsync( + async () => entryInstance.set('counter', LiveCounter.create('foo')), + 'Counter value should be a valid number', + ); + await expectToThrowAsync( + async () => entryInstance.set('counter', LiveCounter.create(BigInt(1))), + 'Counter value should be a valid number', + ); + await expectToThrowAsync( + async () => entryInstance.set('counter', LiveCounter.create(true)), + 'Counter value should be a valid number', + ); + await expectToThrowAsync( + async () => entryInstance.set('counter', LiveCounter.create(Symbol())), + 'Counter value should be a valid number', + ); + await expectToThrowAsync( + async () => entryInstance.set('counter', LiveCounter.create({})), + 'Counter value should be a valid number', + ); + await expectToThrowAsync( + async () => entryInstance.set('counter', LiveCounter.create([])), + 'Counter value should be a valid number', + ); + await expectToThrowAsync( + async () => entryInstance.set('counter', LiveCounter.create(entryInstance)), + '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 { entryInstance, helper } = ctx; + + const mapCreatedPromise = waitForMapKeyUpdate(entryInstance, 'map'); + await entryInstance.set('map', LiveMap.create({ foo: 'bar' })); + await mapCreatedPromise; + + const map = entryInstance.get('map'); + + 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').value()).to.equal( + 'bar', + 'Check map assigned to the object tree has the expected value for its string key', + ); + }, + }, + + { + allTransportsAndProtocols: true, + description: 'LiveMap.create() sends MAP_CREATE operation with primitive values', + action: async (ctx) => { + const { helper, entryInstance } = ctx; + + const objectsCreatedPromise = Promise.all( + primitiveMapsFixtures.map((x) => waitForMapKeyUpdate(entryInstance, x.name)), + ); + await Promise.all( + primitiveMapsFixtures.map(async (mapFixture) => { + const entries = mapFixture.entries + ? Object.entries(mapFixture.entries).reduce((acc, [key, 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; + } + + acc[key] = value; + return acc; + }, {}) + : undefined; + + return entryInstance.set(mapFixture.name, LiveMap.create(entries)); + }), + ); + await objectsCreatedPromise; + + for (let i = 0; i < primitiveMapsFixtures.length; i++) { + const map = entryInstance.get(primitiveMapsFixtures[i].name); + const fixture = primitiveMapsFixtures[i]; + + expect(map, `Check map #${i + 1} exists`).to.exist; + 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, + `Check map #${i + 1} has correct number of keys`, + ); + + Object.entries(fixture.entries ?? {}).forEach(([key, keyData]) => { + checkKeyDataOnInstance({ + helper, + key, + keyData, + instance: map, + msg: `Check map #${i + 1} has correct value for "${key}" key`, + }); + }); + } + }, + }, + + { + allTransportsAndProtocols: true, + description: 'LiveMap.create() sends MAP_CREATE operation with reference to another LiveObject', + action: async (ctx) => { + const { entryInstance, helper } = ctx; + + const objectCreatedPromise = waitForMapKeyUpdate(entryInstance, 'map'); + await entryInstance.set( + 'map', + LiveMap.create({ + map: LiveMap.create(), + counter: LiveCounter.create(), + }), + ); + await objectCreatedPromise; + + const map = entryInstance.get('map'); + const nestedMap = map.get('map'); + const nestedCounter = map.get('counter'); + + expect(map, 'Check map exists').to.exist; + 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; + 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; + helper.recordPrivateApi('read.DefaultInstance._value'); + expectInstanceOf( + nestedCounter._value, + 'LiveCounter', + 'Check nested counter instance is of an expected class', + ); + }, + }, + + { + description: + 'value type created with LiveMap.create() with an invalid input throws when assigned to the object tree', + action: async (ctx) => { + const { entryInstance } = ctx; + + await expectToThrowAsync( + async () => entryInstance.set('map', LiveMap.create(null)), + 'Map entries should be a key-value object', + ); + await expectToThrowAsync( + async () => entryInstance.set('map', LiveMap.create('foo')), + 'Map entries should be a key-value object', + ); + await expectToThrowAsync( + async () => entryInstance.set('map', LiveMap.create(1)), + 'Map entries should be a key-value object', + ); + await expectToThrowAsync( + async () => entryInstance.set('map', LiveMap.create(BigInt(1))), + 'Map entries should be a key-value object', + ); + await expectToThrowAsync( + async () => entryInstance.set('map', LiveMap.create(true)), + 'Map entries should be a key-value object', + ); + await expectToThrowAsync( + async () => entryInstance.set('map', LiveMap.create(Symbol())), + 'Map entries should be a key-value object', + ); + + await expectToThrowAsync( + async () => entryInstance.set('map', LiveMap.create({ key: undefined })), + 'Map value data type is unsupported', + ); + await expectToThrowAsync( + async () => entryInstance.set('map', LiveMap.create({ key: null })), + 'Map value data type is unsupported', + ); + await expectToThrowAsync( + async () => entryInstance.set('map', LiveMap.create({ key: BigInt(1) })), + 'Map value data type is unsupported', + ); + await expectToThrowAsync( + async () => entryInstance.set('map', LiveMap.create({ key: Symbol() })), + 'Map value data type is unsupported', + ); + }, + }, + + { + description: 'DefaultBatchContext.get() returns child DefaultBatchContext instances', + action: async (ctx) => { + const { entryInstance } = ctx; + + const objectsCreatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), + waitForMapKeyUpdate(entryInstance, 'primitive'), + ]); + 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 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 context').to.exist; + expectInstanceOf( + ctxCounter, + '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 context').to.exist; + expectInstanceOf( + ctxMap, + '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(ctxNestedCounter, 'Check nested counter object can be accessed from a map in a batch context').to + .exist; + expectInstanceOf( + ctxNestedCounter, + 'DefaultBatchContext', + 'Check nested counter object value obtained in a batch context is of a DefaultBatchContext type', + ); + }); + }, + }, + + { + description: 'DefaultBatchContext access API methods on objects work and are synchronous', + action: async (ctx) => { + const { entryInstance } = ctx; + + const objectsCreatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), + ]); + await entryInstance.set('counter', LiveCounter.create(1)); + await entryInstance.set('map', LiveMap.create({ foo: 'bar' })); + await objectsCreatedPromise; + + await entryInstance.batch((ctx) => { + const ctxCounter = ctx.get('counter'); + const ctxMap = ctx.get('map'); + + expect(ctxCounter.value()).to.equal( + 1, + '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.entries()].map(([key, val]) => [key, val.value()])).to.deep.equal( + [['foo', 'bar']], + 'Check DefaultBatchContext.entries() method works for maps and is synchronous', + ); + expect([...ctxMap.keys()]).to.deep.equal( + ['foo'], + 'Check DefaultBatchContext.keys() method works for maps and is synchronous', + ); + expect([...ctxMap.values()].map((x) => x.value())).to.deep.equal( + ['bar'], + 'Check DefaultBatchContext.values() method works for maps and is synchronous', + ); + }); + }, + }, + + { + description: + 'DefaultBatchContext write API methods on objects do not mutate objects inside the batch function', + action: async (ctx) => { + const { entryInstance } = ctx; + + const objectsCreatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), + ]); + await entryInstance.set('counter', LiveCounter.create(1)); + await entryInstance.set('map', LiveMap.create({ foo: 'bar' })); + await objectsCreatedPromise; + + await entryInstance.batch((ctx) => { + const ctxCounter = ctx.get('counter'); + const ctxMap = ctx.get('map'); + + ctxCounter.increment(10); + expect(ctxCounter.value()).to.equal( + 1, + 'Check DefaultBatchContext.increment() method does not mutate the counter object inside the batch function', + ); + + ctxCounter.decrement(100); + expect(ctxCounter.value()).to.equal( + 1, + 'Check DefaultBatchContext.decrement() method does not mutate the counter object inside the batch function', + ); + + ctxMap.set('baz', 'qux'); + expect( + ctxMap.get('baz'), + 'Check DefaultBatchContext.set() method does not mutate the map object inside the batch function', + ).to.not.exist; + + ctxMap.remove('foo'); + expect(ctxMap.get('foo').value()).to.equal( + 'bar', + 'Check DefaultBatchContext.remove() method does not mutate the map object inside the batch function', + ); + }); + }, + }, + + { + allTransportsAndProtocols: true, + description: 'DefaultBatchContext scheduled mutation operations are applied when batch function finishes', + action: async (ctx) => { + const { entryInstance } = ctx; + + const objectsCreatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), + ]); + await entryInstance.set('counter', LiveCounter.create(1)); + await entryInstance.set('map', LiveMap.create({ foo: 'bar' })); + await objectsCreatedPromise; + + await entryInstance.batch((ctx) => { + const ctxCounter = ctx.get('counter'); + const ctxMap = ctx.get('map'); + + ctxCounter.increment(10); + ctxCounter.decrement(100); + + ctxMap.set('baz', 'qux'); + 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').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: + 'PathObject.batch()/DefaultInstance.batch() can be called without scheduling any mutation operations', + action: async (ctx) => { + const { entryPathObject, entryInstance } = ctx; + + let caughtError; + try { + await entryPathObject.batch((ctx) => {}); + await entryInstance.batch((ctx) => {}); + } catch (error) { + caughtError = error; + } + expect( + caughtError, + `Check batch operation can be called without scheduling any mutation operations, but got error: ${caughtError?.toString()}`, + ).to.not.exist; + }, + }, + + { + description: + 'DefaultBatchContext scheduled mutation operations can be canceled by throwing an error in the batch function', + action: async (ctx) => { + const { entryInstance } = ctx; + + const objectsCreatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'map'), + ]); + await entryInstance.set('counter', LiveCounter.create(1)); + await entryInstance.set('map', LiveMap.create({ foo: 'bar' })); + await objectsCreatedPromise; + + const cancelError = new Error('cancel batch'); + let caughtError; + try { + await entryInstance.batch((ctx) => { + const ctxCounter = ctx.get('counter'); + const ctxMap = ctx.get('map'); + + ctxCounter.increment(10); + ctxCounter.decrement(100); + + ctxMap.set('baz', 'qux'); + ctxMap.remove('foo'); + + throw cancelError; + }); + } catch (error) { + 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').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 function was rethrown by a batch method', + ); + }, + }, + + { + description: `DefaultBatchContext can't be interacted with after batch function finishes`, + action: async (ctx) => { + const { entryInstance } = ctx; + + let savedCtx; + + await entryInstance.batch((ctx) => { + savedCtx = ctx; + }); + + checkBatchContextAccessApiErrors({ + ctx: savedCtx, + errorMsg: 'Batch is closed', + skipId: true, + }); + checkBatchContextWriteApiErrors({ + ctx: savedCtx, + errorMsg: 'Batch is closed', + }); + }, + }, + + { + description: `DefaultBatchContext can't be interacted with after error was thrown from batch function`, + action: async (ctx) => { + const { entryInstance } = ctx; + + let savedCtx; + let caughtError; + try { + await entryInstance.batch((ctx) => { + savedCtx = ctx; + throw new Error('cancel batch'); + }); + } catch (error) { + caughtError = error; + } + + expect(caughtError, 'Check batch call failed with an error').to.exist; + checkBatchContextAccessApiErrors({ + ctx: savedCtx, + errorMsg: 'Batch is closed', + skipId: true, + }); + checkBatchContextWriteApiErrors({ + ctx: savedCtx, + errorMsg: 'Batch is closed', + }); + }, + }, + ]; + + const pathObjectScenarios = [ + { + description: 'RealtimeObject.get() 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 { entryPathObject, entryInstance } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'nested'); + await entryPathObject.set( + 'nested', + LiveMap.create({ + simple: 'value', + 'key.with.dots': 'dottedValue', + 'key\\escaped': 'escapedValue', + deep: LiveMap.create({ nested: 'deepValue' }), + }), + ); + await keyUpdatedPromise; + + // 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 { entryPathObject, entryInstance } = ctx; + + // Create nested structure + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'nested'); + await entryPathObject.set( + 'nested', + LiveMap.create({ deepKey: 'deepValue', 'key.with.dots': 'dottedValue' }), + ); + 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 { entryPathObject, entryInstance } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'nested.key'); + await entryPathObject.set( + 'nested.key', + LiveMap.create({ + 'key.with.dots.and\\escaped\\characters': 'nestedValue', + }), + ); + 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 { entryPathObject, helper, entryInstance } = 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; + + // check PathObject returns primitive values correctly + primitiveKeyData.forEach((keyData) => { + checkKeyDataOnPathObject({ + helper, + key: keyData.key, + keyData, + 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 { entryPathObject, entryInstance } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); + await entryPathObject.set('counter', LiveCounter.create(10)); + await keyUpdatedPromise; + + const counterPathObj = entryPathObject.get('counter'); + + expect(counterPathObj.value()).to.equal(10, 'Check counter value is returned correctly'); + }, + }, + + { + description: 'PathObject.instance() returns DefaultInstance for LiveMap and LiveCounter', + action: async (ctx) => { + const { entryPathObject, entryInstance } = ctx; + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + ]); + await entryPathObject.set('map', LiveMap.create()); + await entryPathObject.set('counter', LiveCounter.create()); + 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 collection methods work for LiveMap objects', + action: async (ctx) => { + const { entryPathObject, entryInstance } = ctx; + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'key1'), + waitForMapKeyUpdate(entryInstance, 'key2'), + waitForMapKeyUpdate(entryInstance, 'key3'), + ]); + await entryPathObject.set('key1', 'value1'); + await entryPathObject.set('key2', 'value2'); + await entryPathObject.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'); + + 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 { entryPathObject, helper, entryInstance } = 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; + + // check primitive values were set correctly via PathObject + primitiveKeyData.forEach((keyData) => { + checkKeyDataOnPathObject({ + helper, + key: keyData.key, + keyData, + 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 { entryPathObject, entryInstance } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counterKey'); + await entryPathObject.set('counterKey', LiveCounter.create(5)); + await keyUpdatedPromise; + + 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'); + }, + }, + + { + description: 'PathObject.remove() works for LiveMap objects', + action: async (ctx) => { + const { entryPathObject, entryInstance } = ctx; + + const keyAddedPromise = waitForMapKeyUpdate(entryInstance, 'keyToRemove'); + await entryPathObject.set('keyToRemove', 'valueToRemove'); + await keyAddedPromise; + + expect(entryPathObject.get('keyToRemove'), 'Check key exists on root').to.exist; + + const keyRemovedPromise = waitForMapKeyUpdate(entryInstance, 'keyToRemove'); + await entryPathObject.remove('keyToRemove'); + await keyRemovedPromise; + + 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()', + ).to.be.undefined; + }, + }, + + { + description: 'PathObject.increment() and PathObject.decrement() work for LiveCounter objects', + action: async (ctx) => { + const { entryPathObject, entryInstance } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); + await entryPathObject.set('counter', LiveCounter.create(10)); + await keyUpdatedPromise; + + const counter = entryInstance.get('counter'); + 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.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') + .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, + }); + await expectToThrowAsync(async () => nonExistentPathObj.batch(), errorMsg, { + withCode: 92005, + }); + }, + }, + + { + description: 'PathObject handling of operations for paths with non-collection intermediate segments', + action: async (ctx) => { + const { entryPathObject, entryInstance } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); + await entryPathObject.set('counter', LiveCounter.create()); + 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.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') + .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, + }); + await expectToThrowAsync(async () => wrongTypePathObj.batch(), errorMsg, { + withCode: 92005, + }); + }, + }, + + { + description: 'PathObject handling of operations on wrong underlying object type', + action: async (ctx) => { + const { entryPathObject, entryInstance } = ctx; + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'primitive'), + ]); + await entryPathObject.set('map', LiveMap.create()); + await entryPathObject.set('counter', LiveCounter.create()); + await entryPathObject.set('primitive', 'value'); + await keysUpdatedPromise; + + const mapPathObj = entryPathObject.get('map'); + const counterPathObj = entryPathObject.get('counter'); + const primitivePathObj = entryPathObject.get('primitive'); + + // 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', + ); + 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 at path', + { withCode: 92007 }, + ); + await expectToThrowAsync( + async () => counterPathObj.set('key', 'value'), + '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 at path', + { withCode: 92007 }, + ); + await expectToThrowAsync( + async () => counterPathObj.remove('key'), + 'Cannot remove a key from a non-LiveMap object at path', + { withCode: 92007 }, + ); + + // counter mutation methods throw errors for non-LiveCounter objects + await expectToThrowAsync( + async () => primitivePathObj.increment(), + 'Cannot increment a non-LiveCounter object at path', + { 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 at path', + { withCode: 92007 }, + ); + await expectToThrowAsync( + async () => mapPathObj.decrement(), + 'Cannot decrement a non-LiveCounter object at path', + { 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 }, + ); + }, + }, + + { + description: 'PathObject.subscribe() receives events for direct changes to the subscribed path', + action: async (ctx) => { + const { 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 entryPathObject.set('testKey', 'testValue'); + await subscriptionPromise; + }, + }, + + { + description: 'PathObject.subscribe() receives events for nested changes with unlimited depth by default', + action: async (ctx) => { + const { entryPathObject, entryInstance } = 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(entryInstance, 'nested'); + await entryPathObject.set('nested', LiveMap.create()); + await keyUpdatedPromise; + + // nested change + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance.get('nested'), 'child'); + await entryPathObject.get('nested').set('child', LiveMap.create()); + await keyUpdatedPromise; + + // nested child change + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance.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 { entryPathObject, entryInstance } = ctx; + + // Create nested structure + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, '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(entryInstance.get('nested').get('counter')); + await entryPathObject.get('nested').get('counter').increment(); + await counterUpdatedPromise; + + 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(entryInstance, '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 { entryPathObject, entryInstance } = ctx; + + // Create nested structure + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, '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(entryInstance, 'foo'); + await entryPathObject.set('foo', 'bar'); + await keyUpdatedPromise; + + // Next changes should trigger the subscription + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance.get('nested'), 'foo'); + await entryPathObject.get('nested').set('foo', 'bar'); + await keyUpdatedPromise; + + 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(entryInstance, '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 { entryPathObject, entryInstance } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, '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(entryInstance.get('escaped\\key').get('key.with.dots')); + await complexPathObject.increment(); + await counterUpdatedPromise; + + await subscriptionPromise; + }, + }, + + { + description: 'PathObject.subscribe() on LiveMap path receives set/remove events', + action: async (ctx) => { + const { entryPathObject, entryInstance } = ctx; + + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, '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(entryInstance.get('map'), 'foo'); + await entryPathObject.get('map').set('foo', 'bar'); + await keyUpdatedPromise; + + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance.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 { entryPathObject, entryInstance } = ctx; + + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, '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(entryInstance.get('counter')); + await entryPathObject.get('counter').increment(); + await counterUpdatedPromise; + + counterUpdatedPromise = waitForCounterUpdate(entryInstance.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 { entryPathObject, entryInstance } = ctx; + + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, '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(entryInstance, 'other'); + await entryPathObject.set('other', 'bar'); + await keyUpdatedPromise; + + // Only changes to the primitive path should trigger the subscription + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'primitive'); + await entryPathObject.set('primitive', 'baz'); + await keyUpdatedPromise; + + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'primitive'); + await entryPathObject.set('primitive', 42); + await keyUpdatedPromise; + + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, '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 { entryPathObject, entryInstance } = ctx; + + let eventCount = 0; + const { unsubscribe } = entryPathObject.subscribe(() => { + eventCount++; + }); + + // Make first change - should receive event + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'key1'); + await entryPathObject.set('key1', 'value1'); + await keyUpdatedPromise; + + unsubscribe(); + + // Make second change - should NOT receive event + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, '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 { entryPathObject, entryInstance } = 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(entryInstance, 'key1'); + await entryPathObject.set('key1', 'value1'); + await keyUpdatedPromise; + + // Unsubscribe first subscription + unsubscribe1(); + + // Make another change - only second should receive event + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, '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 { entryPathObject, entryInstance } = 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(entryInstance, 'foo'); + await entryPathObject.set('foo', 'bar'); + await keyUpdatedPromise; + + await subscriptionPromise; + }, + }, + + { + description: 'PathObject.subscribe() handles subscription listener errors gracefully', + action: async (ctx) => { + const { entryPathObject, entryInstance } = 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(entryInstance, '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'); + }, + }, + + { + 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 value as is 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 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({ + stringKey: 'stringValue', + numberKey: 123, + booleanKey: true, + counterKey: LiveCounter.create(99), + array: [1, 2, 3], + obj: { nested: 'value' }, + buffer: bufferValue, + }), + ); + 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' }, + buffer: bufferValue, + }; + + 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'); + }, + }, + + { + 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 pointer (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.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) => { + 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 = [ + { + description: 'DefaultInstance.id returns object ID of the underlying LiveObject', + action: async (ctx) => { + const { objectsHelper, channelName, entryInstance } = ctx; + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + ]); + 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 = 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'); + }, + }, + + { + description: 'DefaultInstance.get() returns child DefaultInstance instances', + action: async (ctx) => { + const { entryPathObject, entryInstance } = ctx; + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'stringKey'), + waitForMapKeyUpdate(entryInstance, 'counterKey'), + ]); + await entryPathObject.set('stringKey', 'value'); + await entryPathObject.set('counterKey', LiveCounter.create(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.value() returns primitive values correctly', + action: async (ctx) => { + const { entryPathObject, helper, entryInstance } = 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; + + 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 { entryPathObject, entryInstance } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); + await entryPathObject.set('counter', LiveCounter.create(10)); + await keyUpdatedPromise; + + const counterInstance = entryPathObject.get('counter').instance(); + + expect(counterInstance.value()).to.equal(10, 'Check counter value is returned correctly'); + }, + }, + + { + description: 'DefaultInstance collection methods work for LiveMap objects', + action: async (ctx) => { + const { entryPathObject, entryInstance } = ctx; + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'key1'), + waitForMapKeyUpdate(entryInstance, 'key2'), + waitForMapKeyUpdate(entryInstance, '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'); + + 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'); + }, + }, + + { + description: 'DefaultInstance.set() works for LiveMap objects with primitive values', + action: async (ctx) => { + const { entryPathObject, helper, entryInstance } = ctx; + + const rootInstance = entryPathObject.instance(); + + 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 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 { entryInstance } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counterKey'); + await entryInstance.set('counterKey', LiveCounter.create(5)); + await keyUpdatedPromise; + + 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 { entryPathObject, entryInstance } = ctx; + + 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(entryInstance, 'keyToRemove'); + await entryInstance.remove('keyToRemove'); + await keyRemovedPromise; + + expect( + entryInstance.get('keyToRemove'), + 'Check value for instance is undefined after DefaultInstance.remove()', + ).to.be.undefined; + }, + }, + + { + description: 'DefaultInstance.increment() and DefaultInstance.decrement() work for LiveCounter objects', + action: async (ctx) => { + const { entryPathObject, entryInstance } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'counter'); + await entryPathObject.set('counter', LiveCounter.create(10)); + await keyUpdatedPromise; + + const counter = entryInstance.get('counter'); + + let counterUpdatedPromise = waitForCounterUpdate(counter); + await counter.increment(5); + await counterUpdatedPromise; + + expect(counter.value()).to.equal(15, 'Check DefaultInstance reflects incremented value'); + + counterUpdatedPromise = waitForCounterUpdate(counter); + await counter.decrement(3); + await counterUpdatedPromise; + + expect(counter.value()).to.equal(12, 'Check DefaultInstance reflects decremented value'); + + // test increment/decrement without argument (should increment/decrement by 1) + counterUpdatedPromise = waitForCounterUpdate(counter); + await counter.increment(); + await counterUpdatedPromise; + + expect(counter.value()).to.equal(13, 'Check DefaultInstance reflects incremented value'); + + counterUpdatedPromise = waitForCounterUpdate(counter); + await counter.decrement(); + await counterUpdatedPromise; + + expect(counter.value()).to.equal(12, 'Check DefaultInstance reflects decremented 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 handling of operations on wrong underlying object type', + action: async (ctx) => { + const { entryPathObject, entryInstance } = ctx; + + const keysUpdatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + waitForMapKeyUpdate(entryInstance, 'primitive'), + ]); + await entryPathObject.set('map', LiveMap.create({ foo: 'bar' })); + await entryPathObject.set('counter', LiveCounter.create()); + await entryPathObject.set('primitive', 'value'); + await keysUpdatedPromise; + + 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.set('key', 'value'), + 'Cannot set a key on a non-LiveMap instance', + { withCode: 92007 }, + ); + await expectToThrowAsync( + async () => counterInstance.set('key', 'value'), + 'Cannot set a key on a non-LiveMap instance', + { withCode: 92007 }, + ); + + 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 }, + ); + + // next methods throw errors for non-LiveObjects + expect(() => { + primitiveInstance.subscribe(() => {}); + }) + .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); + await expectToThrowAsync( + async () => primitiveInstance.batch(), + 'Cannot batch operations on a non-LiveObject instance', + { withCode: 92007 }, + ); + }, + }, + + { + description: 'DefaultInstance.subscribe() receives events for LiveMap set/remove operations', + action: async (ctx) => { + const { entryPathObject, entryInstance } = ctx; + + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, '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(entryInstance.get('map'), 'foo'); + await entryPathObject.get('map').set('foo', 'bar'); + await keyUpdatedPromise; + + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance.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 { entryPathObject, entryInstance } = ctx; + + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, '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(entryInstance.get('counter')); + await entryPathObject.get('counter').increment(); + await counterUpdatedPromise; + + counterUpdatedPromise = waitForCounterUpdate(entryInstance.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 { entryPathObject, entryInstance } = ctx; + + let eventCount = 0; + const { unsubscribe } = entryPathObject.instance().subscribe(() => { + eventCount++; + }); + + // Make first change - should receive event + let keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'key1'); + await entryPathObject.set('key1', 'value1'); + await keyUpdatedPromise; + + unsubscribe(); + + // Make second change - should NOT receive event + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, '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 { entryPathObject, entryInstance } = 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(entryInstance, 'key1'); + await entryPathObject.set('key1', 'value1'); + await keyUpdatedPromise; + + // Unsubscribe first subscription + unsubscribe1(); + + // Make another change - only second should receive event + keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, '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 { entryPathObject, entryInstance } = ctx; + + const subscriptionPromise = new Promise((resolve, reject) => { + 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(entryInstance, 'Check event object is the same instance'); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'foo'); + await entryPathObject.set('foo', 'bar'); + await keyUpdatedPromise; + + await subscriptionPromise; + }, + }, + + { + description: 'DefaultInstance.subscribe() handles subscription listener errors gracefully', + action: async (ctx) => { + const { entryPathObject, entryInstance } = 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(entryInstance, '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: '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 value as is 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 compactValue = instance.compact(); + const expectedValue = instance.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 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({ + stringKey: 'stringValue', + numberKey: 456, + booleanKey: false, + counterKey: LiveCounter.create(111), + array: [1, 2, 3], + obj: { nested: 'value' }, + buffer: bufferValue, + }), + ); + 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' }, + buffer: bufferValue, + }; + + 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, 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 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', + ); + }, + }, + + { + 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 pointer (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.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) => { + 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 */ + forScenarios( + this, + [ + ...objectSyncSequenceScenarios, + ...applyOperationsScenarios, + ...applyOperationsDuringSyncScenarios, + ...writeApiScenarios, + ...pathObjectScenarios, + ...instanceScenarios, + ], + async function (helper, scenario, clientOptions, channelName) { + const objectsHelper = new LiveObjectsHelper(helper); + const client = RealtimeWithLiveObjects(helper, clientOptions); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + const channel = client.channels.get(channelName, channelOptionsWithObjectModes()); + const realtimeObject = channel.object; + + await channel.attach(); + const entryPathObject = await realtimeObject.get(); + const entryInstance = entryPathObject.instance(); + + await scenario.action({ + realtimeObject, + entryPathObject, + entryInstance, + objectsHelper, + channelName, + channel, + client, + helper, + clientOptions, + }); + }, client); + }, + ); + + const subscriptionCallbacksScenarios = [ + { + allTransportsAndProtocols: true, + description: 'can subscribe to the incoming COUNTER_INC operation on a LiveCounter', + action: async (ctx) => { + const { objectsHelper, channelName, sampleCounterKey, sampleCounterObjectId, entryInstance } = ctx; + + const counter = entryInstance.get(sampleCounterKey); + const subscriptionPromise = new Promise((resolve, reject) => + counter.subscribe((event) => { + try { + 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) { + reject(error); + } + }), + ); + + await objectsHelper.operationRequest( + channelName, + objectsHelper.counterIncRestOp({ + objectId: sampleCounterObjectId, + number: 1, + }), + ); + + await subscriptionPromise; + }, + }, + + { + allTransportsAndProtocols: true, + description: 'can subscribe to multiple incoming operations on a LiveCounter', + action: async (ctx) => { + const { objectsHelper, channelName, sampleCounterKey, sampleCounterObjectId, entryInstance } = ctx; + + 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((event) => { + try { + const expectedInc = expectedCounterIncrements[currentUpdateIndex]; + 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) { + resolve(); + } + + currentUpdateIndex++; + } catch (error) { + reject(error); + } + }), + ); + + for (const increment of expectedCounterIncrements) { + await objectsHelper.operationRequest( + channelName, + objectsHelper.counterIncRestOp({ + objectId: sampleCounterObjectId, + number: increment, + }), + ); + } + + await subscriptionPromise; + }, + }, + + { + allTransportsAndProtocols: true, + description: 'can subscribe to the incoming MAP_SET operation on a LiveMap', + action: async (ctx) => { + const { objectsHelper, channelName, sampleMapKey, sampleMapObjectId, entryInstance } = ctx; + + const map = entryInstance.get(sampleMapKey); + const subscriptionPromise = new Promise((resolve, reject) => + map.subscribe((event) => { + try { + 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) { + reject(error); + } + }), + ); + + await objectsHelper.operationRequest( + channelName, + objectsHelper.mapSetRestOp({ + objectId: sampleMapObjectId, + key: 'stringKey', + value: { string: 'stringValue' }, + }), + ); + + await subscriptionPromise; + }, + }, + + { + allTransportsAndProtocols: true, + description: 'can subscribe to the incoming MAP_REMOVE operation on a LiveMap', + action: async (ctx) => { + const { objectsHelper, channelName, sampleMapKey, sampleMapObjectId, entryInstance } = ctx; + + const map = entryInstance.get(sampleMapKey); + const subscriptionPromise = new Promise((resolve, reject) => + map.subscribe((event) => { + try { + 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) { + reject(error); + } + }), + ); + + await objectsHelper.operationRequest( + channelName, + objectsHelper.mapRemoveRestOp({ + objectId: sampleMapObjectId, + key: 'stringKey', + }), + ); + + await subscriptionPromise; + }, + }, + + { + allTransportsAndProtocols: true, + description: 'can subscribe to multiple incoming operations on a LiveMap', + action: async (ctx) => { + const { objectsHelper, channelName, sampleMapKey, sampleMapObjectId, entryInstance } = ctx; + + const map = entryInstance.get(sampleMapKey); + const expectedMapUpdates = [ + { 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(({ message }) => { + try { + expect(message?.operation).to.deep.include( + expectedMapUpdates[currentUpdateIndex], + `Check map subscription callback is called with an expected event message operation for ${currentUpdateIndex + 1} times`, + ); + + if (currentUpdateIndex === expectedMapUpdates.length - 1) { + resolve(); + } + + currentUpdateIndex++; + } catch (error) { + reject(error); + } + }), + ); + + await objectsHelper.operationRequest( + channelName, + objectsHelper.mapSetRestOp({ + objectId: sampleMapObjectId, + key: 'foo', + value: { string: '1' }, + }), + ); + + await objectsHelper.operationRequest( + channelName, + objectsHelper.mapSetRestOp({ + objectId: sampleMapObjectId, + key: 'bar', + value: { string: '2' }, + }), + ); + + await objectsHelper.operationRequest( + channelName, + objectsHelper.mapRemoveRestOp({ + objectId: sampleMapObjectId, + key: 'foo', + }), + ); + + await objectsHelper.operationRequest( + channelName, + objectsHelper.mapSetRestOp({ + objectId: sampleMapObjectId, + key: 'baz', + value: { string: '3' }, + }), + ); + + await objectsHelper.operationRequest( + channelName, + objectsHelper.mapRemoveRestOp({ + objectId: sampleMapObjectId, + key: 'bar', + }), + ); + + await subscriptionPromise; + }, + }, + + { + 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 = RealtimeWithLiveObjects(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, channelOptionsWithObjectModes()); + 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 { objectsHelper, channelName, sampleCounterKey, sampleCounterObjectId, entryInstance } = ctx; + + const counter = entryInstance.get(sampleCounterKey); + let callbackCalled = 0; + const subscriptionPromise = new Promise((resolve) => { + const { unsubscribe } = counter.subscribe(() => { + callbackCalled++; + // unsubscribe from future updates after the first call + unsubscribe(); + resolve(); + }); + }); + + 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; + } + + await subscriptionPromise; + + expect(counter.value()).to.equal(3, 'Check counter has final expected value after all increments'); + expect(callbackCalled).to.equal(1, 'Check subscription callback was only called once'); + }, + }, + + { + skip: true, // TODO: replace with instance/pathobject .unsubscribe() call + description: 'can unsubscribe from LiveCounter updates via LiveCounter.unsubscribe() call', + action: async (ctx) => { + const { objectsHelper, channelName, sampleCounterKey, sampleCounterObjectId, entryInstance } = ctx; + + const counter = entryInstance.get(sampleCounterKey); + let callbackCalled = 0; + const subscriptionPromise = new Promise((resolve) => { + const listener = () => { + callbackCalled++; + // unsubscribe from future updates after the first call + counter.unsubscribe(listener); + resolve(); + }; + + counter.subscribe(listener); + }); + + 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; + } + + await subscriptionPromise; + + expect(counter.value()).to.equal(3, 'Check counter has final expected value after all increments'); + expect(callbackCalled).to.equal(1, 'Check subscription callback was only called once'); + }, + }, + + { + description: 'can unsubscribe from LiveMap updates via returned "unsubscribe" callback', + action: async (ctx) => { + const { objectsHelper, channelName, sampleMapKey, sampleMapObjectId, entryInstance } = ctx; + + const map = entryInstance.get(sampleMapKey); + let callbackCalled = 0; + const subscriptionPromise = new Promise((resolve) => { + const { unsubscribe } = map.subscribe(() => { + callbackCalled++; + // unsubscribe from future updates after the first call + unsubscribe(); + resolve(); + }); + }); + + 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; + } + + await subscriptionPromise; + + for (let i = 0; i < mapSets; i++) { + expect(map.get(`foo-${i}`).value()).to.equal( + 'exists', + `Check map has value for key "foo-${i}" after all map sets`, + ); + } + expect(callbackCalled).to.equal(1, 'Check subscription callback was only called once'); + }, + }, + + { + skip: true, // TODO: replace with instance/pathobject .unsubscribe() call + description: 'can unsubscribe from LiveMap updates via LiveMap.unsubscribe() call', + action: async (ctx) => { + const { objectsHelper, channelName, sampleMapKey, sampleMapObjectId, entryInstance } = ctx; + + const map = entryInstance.get(sampleMapKey); + let callbackCalled = 0; + const subscriptionPromise = new Promise((resolve) => { + const listener = () => { + callbackCalled++; + // unsubscribe from future updates after the first call + map.unsubscribe(listener); + resolve(); + }; + + map.subscribe(listener); + }); + + 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; + } + + await subscriptionPromise; + + 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`, + ); + } + expect(callbackCalled).to.equal(1, 'Check subscription callback was only called once'); + }, + }, + ]; + + /** @nospec */ + forScenarios(this, subscriptionCallbacksScenarios, async function (helper, scenario, clientOptions, channelName) { + const objectsHelper = new LiveObjectsHelper(helper); + const client = RealtimeWithLiveObjects(helper, clientOptions); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + const channel = client.channels.get(channelName, channelOptionsWithObjectModes()); + + await channel.attach(); + const entryPathObject = await channel.object.get(); + const entryInstance = entryPathObject.instance(); + + const sampleMapKey = 'sampleMap'; + const sampleCounterKey = 'sampleCounter'; + + const objectsCreatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, sampleMapKey), + waitForMapKeyUpdate(entryInstance, sampleCounterKey), + ]); + // prepare map and counter objects for use by the scenario + const { objectId: sampleMapObjectId } = await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: sampleMapKey, + createOp: objectsHelper.mapCreateRestOp(), + }); + const { objectId: sampleCounterObjectId } = await objectsHelper.createAndSetOnMap(channelName, { + mapObjectId: 'root', + key: sampleCounterKey, + createOp: objectsHelper.counterCreateRestOp(), + }); + await objectsCreatedPromise; + + await scenario.action({ + entryPathObject, + entryInstance, + objectsHelper, + channelName, + channel, + sampleMapKey, + sampleMapObjectId, + sampleCounterKey, + sampleCounterObjectId, + helper, + }); + }, client); + }); + + it('gcGracePeriod is set from connectionDetails.objectsGCGracePeriod', async function () { + const helper = this.test.helper; + const client = RealtimeWithLiveObjects(helper); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + await client.connection.once('connected'); + + const channel = client.channels.get('channel', channelOptionsWithObjectModes()); + const realtimeObject = channel.object; + const connectionManager = client.connection.connectionManager; + const connectionDetails = connectionManager.connectionDetails; + + // gcGracePeriod should be set after the initial connection + helper.recordPrivateApi('read.RealtimeObject.gcGracePeriod'); + expect( + realtimeObject.gcGracePeriod, + 'Check gcGracePeriod is set after initial connection from connectionDetails.objectsGCGracePeriod', + ).to.exist; + helper.recordPrivateApi('read.RealtimeObject.gcGracePeriod'); + expect(realtimeObject.gcGracePeriod).to.equal( + connectionDetails.objectsGCGracePeriod, + 'Check gcGracePeriod is set to equal connectionDetails.objectsGCGracePeriod', + ); + + const connectionDetailsPromise = connectionManager.once('connectiondetails'); + + helper.recordPrivateApi('call.connectionManager.activeProtocol.getTransport'); + helper.recordPrivateApi('call.transport.onProtocolMessage'); + helper.recordPrivateApi('call.makeProtocolMessageFromDeserialized'); + connectionManager.activeProtocol.getTransport().onProtocolMessage( + createPM({ + action: 4, // CONNECTED + connectionDetails: { + ...connectionDetails, + objectsGCGracePeriod: 999, + }, + }), + ); + + helper.recordPrivateApi('listen.connectionManager.connectiondetails'); + await connectionDetailsPromise; + // 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'); + expect(realtimeObject.gcGracePeriod).to.equal(999, 'Check gcGracePeriod is updated on new CONNECTED event'); + }, client); + }); + + it('gcGracePeriod has a default value if connectionDetails.objectsGCGracePeriod is missing', async function () { + const helper = this.test.helper; + const client = RealtimeWithLiveObjects(helper); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + await client.connection.once('connected'); + + const channel = client.channels.get('channel', channelOptionsWithObjectModes()); + const realtimeObject = channel.object; + const connectionManager = client.connection.connectionManager; + const connectionDetails = connectionManager.connectionDetails; + + helper.recordPrivateApi('read.RealtimeObject._DEFAULTS.gcGracePeriod'); + helper.recordPrivateApi('write.RealtimeObject.gcGracePeriod'); + // set gcGracePeriod to a value different from the default + realtimeObject.gcGracePeriod = LiveObjectsPlugin.RealtimeObject._DEFAULTS.gcGracePeriod + 1; + + const connectionDetailsPromise = connectionManager.once('connectiondetails'); + + // send a CONNECTED event without objectsGCGracePeriod, it should use the default value instead + helper.recordPrivateApi('call.connectionManager.activeProtocol.getTransport'); + helper.recordPrivateApi('call.transport.onProtocolMessage'); + helper.recordPrivateApi('call.makeProtocolMessageFromDeserialized'); + connectionManager.activeProtocol.getTransport().onProtocolMessage( + createPM({ + action: 4, // CONNECTED + connectionDetails, + }), + ); + + helper.recordPrivateApi('listen.connectionManager.connectiondetails'); + await connectionDetailsPromise; + // 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( + 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 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, + LiveObjectsHelper.ACTIONS.COUNTER_CREATE, + ); + // send a CREATE op, this adds an object to the pool + const { objectId } = await objectsHelper.operationRequest( + channelName, + objectsHelper.counterCreateRestOp({ number: 1 }), + ); + await counterCreatedPromise; + + 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({ + channel, + serial: lexicoTimeserial('aaa', 0, 0), + siteCode: 'aaa', + state: [objectsHelper.objectDeleteOp({ objectId })], + }); + + helper.recordPrivateApi('call.RealtimeObject._objectsPool.get'); + expect( + realtimeObject._objectsPool.get(objectId), + 'Check object exists in the pool immediately after OBJECT_DELETE', + ).to.exist; + helper.recordPrivateApi('call.RealtimeObject._objectsPool.get'); + helper.recordPrivateApi('call.LiveObject.isTombstoned'); + expect(realtimeObject._objectsPool.get(objectId).isTombstoned()).to.equal( + true, + `Check object's "tombstone" flag is set to "true" after OBJECT_DELETE`, + ); + + // we expect 2 cycles to guarantee that grace period has expired, which will always be true based on the test config used + await waitForGCCycles(2); + + // object should be removed from the local pool entirely now, as the GC grace period has passed + helper.recordPrivateApi('call.RealtimeObject._objectsPool.get'); + expect( + realtimeObject._objectsPool.get(objectId), + 'Check object exists does not exist in the pool after the GC grace period expiration', + ).to.not.exist; + }, + }, + + { + allTransportsAndProtocols: true, + description: 'tombstoned map entry is removed from the LiveMap after the GC grace period', + action: async (ctx) => { + const { entryInstance, objectsHelper, channelName, helper, waitForGCCycles } = ctx; + + const keyUpdatedPromise = waitForMapKeyUpdate(entryInstance, 'foo'); + // set a key on a root + await objectsHelper.operationRequest( + channelName, + objectsHelper.mapSetRestOp({ objectId: 'root', key: 'foo', value: { string: 'bar' } }), + ); + await keyUpdatedPromise; + + 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 + await objectsHelper.operationRequest( + channelName, + objectsHelper.mapRemoveRestOp({ objectId: 'root', key: 'foo' }), + ); + await keyUpdatedPromise2; + + 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( + 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( + 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; + + // we expect 2 cycles to guarantee that grace period has expired, which will always be true based on the test config used + 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( + 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; + }, + }, + ]; + + /** @nospec */ + forScenarios(this, tombstonesGCScenarios, async function (helper, scenario, clientOptions, channelName) { + try { + helper.recordPrivateApi('write.RealtimeObject._DEFAULTS.gcInterval'); + LiveObjectsPlugin.RealtimeObject._DEFAULTS.gcInterval = 500; + + const objectsHelper = new LiveObjectsHelper(helper); + const client = RealtimeWithLiveObjects(helper, clientOptions); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + const channel = client.channels.get(channelName, channelOptionsWithObjectModes()); + const realtimeObject = channel.object; + + await channel.attach(); + const entryPathObject = await channel.object.get(); + const entryInstance = entryPathObject.instance(); + + 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 = realtimeObject._objectsPool._onGCInterval; + let gcCalledTimes = 0; + return new Promise((resolve) => { + helper.recordPrivateApi('replace.RealtimeObject._objectsPool._onGCInterval'); + realtimeObject._objectsPool._onGCInterval = function () { + helper.recordPrivateApi('call.RealtimeObject._objectsPool._onGCInterval'); + onGCIntervalOriginal.call(this); + + gcCalledTimes++; + if (gcCalledTimes >= cycles) { + resolve(); + realtimeObject._objectsPool._onGCInterval = onGCIntervalOriginal; + } + }; + }); + }; + + await scenario.action({ + client, + entryPathObject, + entryInstance, + objectsHelper, + channelName, + channel, + realtimeObject, + helper, + waitForGCCycles, + }); + + helper.recordPrivateApi('write.RealtimeObject.gcGracePeriod'); + realtimeObject.gcGracePeriod = gcGracePeriodOriginal; + }, client); + } finally { + helper.recordPrivateApi('write.RealtimeObject._DEFAULTS.gcInterval'); + LiveObjectsPlugin.RealtimeObject._DEFAULTS.gcInterval = gcIntervalOriginal; + } + }); + + 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 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); + }; + + /** Make sure to call this inside the batch method as batch objects can't be interacted with outside the batch callback */ + 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.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 checkBatchContextWriteApiErrors = ({ 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, entryPathObject, entryInstance, channel } = ctx; + + // obtain batch context with valid modes first + await entryInstance.batch((ctx) => { + // now simulate missing modes + channel.modes = []; + + checkBatchContextAccessApiErrors({ ctx, errorMsg: '"object_subscribe" channel mode' }); + checkBatchContextWriteApiErrors({ ctx, errorMsg: '"object_publish" channel mode' }); + }); + + await expectToThrowAsync(async () => realtimeObject.get(), '"object_subscribe" channel mode'); + await checkAccessApiErrors({ entryPathObject, entryInstance, errorMsg: '"object_subscribe" channel mode' }); + await checkWriteApiErrors({ entryPathObject, entryInstance, errorMsg: '"object_publish" channel mode' }); + }, + }, + + { + description: + 'public API throws missing object modes error when not yet attached but client options are missing correct modes', + action: async (ctx) => { + const { realtimeObject, entryPathObject, entryInstance, channel, helper } = ctx; + + // obtain batch context with valid modes first + 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 = []; + + checkBatchContextAccessApiErrors({ ctx, errorMsg: '"object_subscribe" channel mode' }); + checkBatchContextWriteApiErrors({ ctx, errorMsg: '"object_publish" channel mode' }); + }); + + await expectToThrowAsync(async () => realtimeObject.get(), '"object_subscribe" 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 { entryPathObject, entryInstance, channel, helper } = ctx; + + // obtain batch context with valid channel state first + await entryInstance.batch((ctx) => { + // now simulate channel state change + helper.recordPrivateApi('call.channel.requestState'); + channel.requestState('detached'); + + checkBatchContextAccessApiErrors({ ctx, errorMsg: 'failed as channel state is detached' }); + checkBatchContextWriteApiErrors({ ctx, errorMsg: 'failed as channel state is detached' }); + }); + + await checkAccessApiErrors({ + entryPathObject, + entryInstance, + errorMsg: 'failed as channel state is detached', + }); + await checkWriteApiErrors({ + entryPathObject, + entryInstance, + errorMsg: 'failed as channel state is detached', + }); + }, + }, + + { + description: 'public API throws invalid channel state error when channel FAILED', + action: async (ctx) => { + const { realtimeObject, entryPathObject, entryInstance, channel, helper } = ctx; + + // obtain batch context with valid channel state first + await entryInstance.batch((ctx) => { + // now simulate channel state change + helper.recordPrivateApi('call.channel.requestState'); + channel.requestState('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 checkAccessApiErrors({ + entryPathObject, + entryInstance, + errorMsg: 'failed as channel state is failed', + }); + await checkWriteApiErrors({ + entryPathObject, + entryInstance, + errorMsg: 'failed as channel state is failed', + }); + }, + }, + + { + description: 'public write API throws invalid channel state error when channel SUSPENDED', + action: async (ctx) => { + const { entryPathObject, entryInstance, channel, helper } = ctx; + + // obtain batch context with valid channel state first + await entryInstance.batch((ctx) => { + // now simulate channel state change + helper.recordPrivateApi('call.channel.requestState'); + channel.requestState('suspended'); + + checkBatchContextWriteApiErrors({ ctx, errorMsg: 'failed as channel state is suspended' }); + }); + + await checkWriteApiErrors({ + entryPathObject, + entryInstance, + errorMsg: 'failed as channel state is suspended', + }); + }, + }, + + { + description: 'public write API throws invalid channel option when "echoMessages" is disabled', + action: async (ctx) => { + const { client, entryPathObject, entryInstance, helper } = ctx; + + // obtain batch context with valid client options first + await entryInstance.batch((ctx) => { + // now simulate echoMessages was disabled + helper.recordPrivateApi('write.realtime.options.echoMessages'); + client.options.echoMessages = false; + + checkBatchContextWriteApiErrors({ ctx, errorMsg: '"echoMessages" client option' }); + }); + + await checkWriteApiErrors({ entryPathObject, entryInstance, errorMsg: '"echoMessages" client option' }); + }, + }, + ]; + + /** @nospec */ + forScenarios(this, clientConfigurationScenarios, async function (helper, scenario, clientOptions, channelName) { + 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, channelOptionsWithObjectModes()); + const realtimeObject = channel.object; + + await channel.attach(); + const entryPathObject = await channel.object.get(); + const entryInstance = entryPathObject.instance(); + + const objectsCreatedPromise = Promise.all([ + waitForMapKeyUpdate(entryInstance, 'map'), + waitForMapKeyUpdate(entryInstance, 'counter'), + ]); + await entryInstance.set('map', LiveMap.create()); + await entryInstance.set('counter', LiveCounter.create()); + await objectsCreatedPromise; + + const map = entryInstance.get('map'); + const counter = entryInstance.get('counter'); + + await scenario.action({ + realtimeObject, + objectsHelper, + channelName, + channel, + entryPathObject, + entryInstance, + map, + counter, + helper, + client, + }); + }, client); + }); + + /** + * @spec TO3l8 + * @spec RSL1i + */ + it('object message publish respects connectionDetails.maxMessageSize', async function () { + const helper = this.test.helper; + const client = RealtimeWithLiveObjects(helper); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + await client.connection.once('connected'); + + const connectionManager = client.connection.connectionManager; + const connectionDetails = connectionManager.connectionDetails; + const connectionDetailsPromise = connectionManager.once('connectiondetails'); + + helper.recordPrivateApi('write.connectionManager.connectionDetails.maxMessageSize'); + connectionDetails.maxMessageSize = 64; + + helper.recordPrivateApi('call.connectionManager.activeProtocol.getTransport'); + helper.recordPrivateApi('call.transport.onProtocolMessage'); + helper.recordPrivateApi('call.makeProtocolMessageFromDeserialized'); + // forge lower maxMessageSize + connectionManager.activeProtocol.getTransport().onProtocolMessage( + createPM({ + action: 4, // CONNECTED + connectionDetails, + }), + ); + + helper.recordPrivateApi('listen.connectionManager.connectiondetails'); + await connectionDetailsPromise; + + const channel = client.channels.get('channel', channelOptionsWithObjectModes()); + + await channel.attach(); + const entryPathObject = await channel.object.get(); + + const data = new Array(100).fill('a').join(''); + const error = await expectToThrowAsync( + async () => entryPathObject.set('key', data), + 'Maximum size of object messages that can be published at once exceeded', + ); + + expect(error.code).to.equal(40009, 'Check maximum size of messages error has correct error code'); + }, client); + }); + + describe('ObjectMessage message size', () => { + const objectMessageSizeScenarios = [ + { + description: 'client id', + message: objectMessageFromValues({ + clientId: 'my-client', + }), + expected: Utils.dataSizeBytes('my-client'), + }, + { + description: 'extras', + message: objectMessageFromValues({ + extras: { foo: 'bar' }, + }), + expected: Utils.dataSizeBytes('{"foo":"bar"}'), + }, + { + description: 'object id', + message: objectMessageFromValues({ + operation: { objectId: 'object-id' }, + }), + expected: 0, + }, + { + description: 'nonce', + message: objectMessageFromValues({ + operation: { nonce: '1234567890' }, + }), + expected: 0, + }, + { + description: 'initial value', + message: objectMessageFromValues({ + operation: { initialValue: JSON.stringify({ counter: { count: 1 } }) }, + }), + expected: 0, + }, + { + description: 'map create op no payload', + message: objectMessageFromValues({ + operation: { action: 0, objectId: 'object-id' }, + }), + expected: 0, + }, + { + description: 'map create op with object id payload', + message: objectMessageFromValues({ + operation: { + action: 0, + objectId: 'object-id', + map: { + semantics: 0, + entries: { 'key-1': { tombstone: false, data: { objectId: 'another-object-id' } } }, + }, + }, + }), + expected: Utils.dataSizeBytes('key-1'), + }, + { + description: 'map create op with string payload', + message: objectMessageFromValues({ + operation: { + action: 0, + objectId: 'object-id', + map: { semantics: 0, entries: { 'key-1': { tombstone: false, data: { value: 'a string' } } } }, + }, + }), + expected: Utils.dataSizeBytes('key-1') + Utils.dataSizeBytes('a string'), + }, + { + description: 'map create op with bytes payload', + message: objectMessageFromValues({ + operation: { + action: 0, + objectId: 'object-id', + map: { + semantics: 0, + entries: { 'key-1': { tombstone: false, data: { value: BufferUtils.utf8Encode('my-value') } } }, + }, + }, + }), + expected: Utils.dataSizeBytes('key-1') + Utils.dataSizeBytes(BufferUtils.utf8Encode('my-value')), + }, + { + description: 'map create op with boolean payload', + message: objectMessageFromValues({ + operation: { + action: 0, + objectId: 'object-id', + map: { + semantics: 0, + entries: { + 'key-1': { tombstone: false, data: { value: true } }, + 'key-2': { tombstone: false, data: { value: false } }, + }, + }, + }, + }), + expected: Utils.dataSizeBytes('key-1') + Utils.dataSizeBytes('key-2') + 2, + }, + { + description: 'map create op with double payload', + message: objectMessageFromValues({ + operation: { + action: 0, + objectId: 'object-id', + map: { + semantics: 0, + entries: { + 'key-1': { tombstone: false, data: { value: 123.456 } }, + 'key-2': { tombstone: false, data: { value: 0 } }, + }, + }, + }, + }), + expected: Utils.dataSizeBytes('key-1') + Utils.dataSizeBytes('key-2') + 16, + }, + { + description: 'map create op with object payload', + message: objectMessageFromValues({ + operation: { + action: 0, + objectId: 'object-id', + map: { + semantics: 0, + entries: { 'key-1': { tombstone: false, data: { value: { foo: 'bar' } } } }, + }, + }, + }), + expected: Utils.dataSizeBytes('key-1') + JSON.stringify({ foo: 'bar' }).length, + }, + { + description: 'map create op with array payload', + message: objectMessageFromValues({ + operation: { + action: 0, + objectId: 'object-id', + map: { + semantics: 0, + entries: { 'key-1': { tombstone: false, data: { value: ['foo', 'bar', 'baz'] } } }, + }, + }, + }), + expected: Utils.dataSizeBytes('key-1') + JSON.stringify(['foo', 'bar', 'baz']).length, + }, + { + description: 'map remove op', + message: objectMessageFromValues({ + operation: { action: 2, objectId: 'object-id', mapOp: { key: 'my-key' } }, + }), + expected: Utils.dataSizeBytes('my-key'), + }, + { + description: 'map set operation value=objectId', + message: objectMessageFromValues({ + operation: { + action: 1, + objectId: 'object-id', + mapOp: { key: 'my-key', data: { objectId: 'another-object-id' } }, + }, + }), + expected: Utils.dataSizeBytes('my-key'), + }, + { + description: 'map set operation value=string', + message: objectMessageFromValues({ + operation: { action: 1, objectId: 'object-id', mapOp: { key: 'my-key', data: { value: 'my-value' } } }, + }), + expected: Utils.dataSizeBytes('my-key') + Utils.dataSizeBytes('my-value'), + }, + { + description: 'map set operation value=bytes', + message: objectMessageFromValues({ + operation: { + action: 1, + objectId: 'object-id', + mapOp: { key: 'my-key', data: { value: BufferUtils.utf8Encode('my-value') } }, + }, + }), + expected: Utils.dataSizeBytes('my-key') + Utils.dataSizeBytes(BufferUtils.utf8Encode('my-value')), + }, + { + description: 'map set operation value=boolean true', + message: objectMessageFromValues({ + operation: { action: 1, objectId: 'object-id', mapOp: { key: 'my-key', data: { value: true } } }, + }), + expected: Utils.dataSizeBytes('my-key') + 1, + }, + { + description: 'map set operation value=boolean false', + message: objectMessageFromValues({ + operation: { action: 1, objectId: 'object-id', mapOp: { key: 'my-key', data: { value: false } } }, + }), + expected: Utils.dataSizeBytes('my-key') + 1, + }, + { + description: 'map set operation value=double', + message: objectMessageFromValues({ + operation: { action: 1, objectId: 'object-id', mapOp: { key: 'my-key', data: { value: 123.456 } } }, + }), + expected: Utils.dataSizeBytes('my-key') + 8, + }, + { + description: 'map set operation value=double 0', + message: objectMessageFromValues({ + operation: { action: 1, objectId: 'object-id', mapOp: { key: 'my-key', data: { value: 0 } } }, + }), + expected: Utils.dataSizeBytes('my-key') + 8, + }, + { + description: 'map set operation value=json-object', + message: objectMessageFromValues({ + operation: { + action: 1, + objectId: 'object-id', + mapOp: { key: 'my-key', data: { value: { foo: 'bar' } } }, + }, + }), + expected: Utils.dataSizeBytes('my-key') + JSON.stringify({ foo: 'bar' }).length, + }, + { + description: 'map set operation value=json-array', + message: objectMessageFromValues({ + operation: { + action: 1, + objectId: 'object-id', + mapOp: { key: 'my-key', data: { value: ['foo', 'bar', 'baz'] } }, + }, + }), + expected: Utils.dataSizeBytes('my-key') + JSON.stringify(['foo', 'bar', 'baz']).length, + }, + { + description: 'map object', + message: objectMessageFromValues({ + object: { + objectId: 'object-id', + map: { + semantics: 0, + entries: { + 'key-1': { tombstone: false, data: { value: 'a string' } }, + 'key-2': { tombstone: true, data: { value: 'another string' } }, + }, + }, + createOp: { + action: 0, + objectId: 'object-id', + map: { semantics: 0, entries: { 'key-3': { tombstone: false, data: { value: 'third string' } } } }, + }, + siteTimeserials: { aaa: lexicoTimeserial('aaa', 111, 111, 1) }, // shouldn't be counted + tombstone: false, + }, + }), + expected: + Utils.dataSizeBytes('key-1') + + Utils.dataSizeBytes('a string') + + Utils.dataSizeBytes('key-2') + + Utils.dataSizeBytes('another string') + + Utils.dataSizeBytes('key-3') + + Utils.dataSizeBytes('third string'), + }, + { + description: 'counter create op no payload', + message: objectMessageFromValues({ + operation: { action: 3, objectId: 'object-id' }, + }), + expected: 0, + }, + { + description: 'counter create op with payload', + message: objectMessageFromValues({ + operation: { action: 3, objectId: 'object-id', counter: { count: 1234567 } }, + }), + expected: 8, + }, + { + description: 'counter inc op', + message: objectMessageFromValues({ + operation: { action: 4, objectId: 'object-id', counterOp: { amount: 123.456 } }, + }), + expected: 8, + }, + { + description: 'counter object', + message: objectMessageFromValues({ + object: { + objectId: 'object-id', + counter: { count: 1234567 }, + createOp: { + action: 3, + objectId: 'object-id', + counter: { count: 9876543 }, + }, + siteTimeserials: { aaa: lexicoTimeserial('aaa', 111, 111, 1) }, // shouldn't be counted + tombstone: false, + }, + }), + expected: 8 + 8, + }, + ]; + + /** @nospec */ + forScenarios(this, objectMessageSizeScenarios, function (helper, scenario) { + 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 + helper.recordPrivateApi('call.ObjectMessage.fromValues'); // was called by a scenario to create an ObjectMessage instance + helper.recordPrivateApi('call.Utils.dataSizeBytes'); // was called by a scenario to calculated the expected byte size + helper.recordPrivateApi('call.ObjectMessage.getMessageSize'); + expect(encodedMessage.getMessageSize()).to.equal(scenario.expected); + }); + }); + }); + + /** @nospec */ + it('can attach to channel with object modes', async function () { + const helper = this.test.helper; + const client = helper.AblyRealtime(); + + await helper.monitorConnectionThenCloseAndFinishAsync(async () => { + const objectsModes = ['object_subscribe', 'object_publish']; + const channelOptions = { modes: objectsModes }; + const channel = client.channels.get('channel', channelOptions); + + await channel.attach(); + + helper.recordPrivateApi('read.channel.channelOptions'); + expect(channel.channelOptions).to.deep.equal(channelOptions, 'Check expected channel options'); + expect(channel.modes).to.deep.equal(objectsModes, 'Check expected modes'); + }, client); + }); + + /** @nospec */ + describe('Sync events', () => { + /** + * Helper function to inject an ATTACHED protocol message with or without HAS_OBJECTS flag + */ + async function injectAttachedMessage(helper, channel, hasObjects) { + helper.recordPrivateApi('call.connectionManager.activeProtocol.getTransport'); + helper.recordPrivateApi('call.transport.onProtocolMessage'); + helper.recordPrivateApi('call.makeProtocolMessageFromDeserialized'); + const transport = channel.client.connection.connectionManager.activeProtocol.getTransport(); + const pm = createPM({ + action: 11, // ATTACHED + channel: channel.name, + flags: hasObjects ? 1 << 7 : 0, // HAS_OBJECTS flag is bit 7 + }); + await transport.onProtocolMessage(pm); + } + + const syncEventsScenarios = [ + // 1. ATTACHED with HAS_OBJECTS false + + { + description: + 'The first ATTACHED should always provoke a SYNCING even when HAS_OBJECTS is false, so that the SYNCED is preceded by SYNCING', + channelEvents: [{ type: 'attached', hasObjects: false }], + expectedSyncEvents: ['syncing', 'synced'], + }, + + { + description: 'ATTACHED with HAS_OBJECTS false once SYNCED emits SYNCING and then SYNCED', + channelEvents: [ + { type: 'attached', hasObjects: false }, + { type: 'attached', hasObjects: false }, + ], + expectedSyncEvents: [ + 'syncing', + 'synced', // The initial SYNCED + 'syncing', + 'synced', // From the subsequent ATTACHED + ], + }, + + { + description: + "If we're in SYNCING awaiting an OBJECT_SYNC but then instead get an ATTACHED with HAS_OBJECTS false, we should emit a SYNCED", + channelEvents: [ + { type: 'attached', hasObjects: true }, + { type: 'attached', hasObjects: false }, + ], + expectedSyncEvents: ['syncing', 'synced'], + }, + + // 2. ATTACHED with HAS_OBJECTS true + + { + description: 'An initial ATTACHED with HAS_OBJECTS true provokes a SYNCING', + channelEvents: [{ type: 'attached', hasObjects: true }], + expectedSyncEvents: ['syncing'], + }, + + { + description: + "ATTACHED with HAS_OBJECTS true when SYNCED should provoke another SYNCING, because we're waiting to receive the updated objects in an OBJECT_SYNC", + channelEvents: [ + { type: 'attached', hasObjects: false }, + { type: 'attached', hasObjects: true }, + ], + expectedSyncEvents: ['syncing', 'synced', 'syncing'], + }, + + { + description: + "If we're in SYNCING awaiting an OBJECT_SYNC but then instead get another ATTACHED with HAS_OBJECTS true, we should remain SYNCING (i.e. not emit another event)", + channelEvents: [ + { type: 'attached', hasObjects: true }, + { type: 'attached', hasObjects: true }, + ], + expectedSyncEvents: ['syncing'], + }, + + // 3. OBJECT_SYNC straight after ATTACHED + + { + description: 'A complete multi-message OBJECT_SYNC sequence after ATTACHED emits SYNCING and then SYNCED', + channelEvents: [ + { type: 'attached', hasObjects: true }, + { type: 'objectSync', channelSerial: 'foo:1' }, + { type: 'objectSync', channelSerial: 'foo:2' }, + { type: 'objectSync', channelSerial: 'foo:' }, + ], + expectedSyncEvents: ['syncing', 'synced'], + }, + + { + description: 'A complete single-message OBJECT_SYNC after ATTACHED emits SYNCING and then SYNCED', + channelEvents: [ + { type: 'attached', hasObjects: true }, + { type: 'objectSync', channelSerial: 'foo:' }, + ], + expectedSyncEvents: ['syncing', 'synced'], + }, + + { + description: 'SYNCED is not emitted midway through a multi-message OBJECT_SYNC sequence', + channelEvents: [ + { type: 'attached', hasObjects: true }, + { type: 'objectSync', channelSerial: 'foo:1' }, + { type: 'objectSync', channelSerial: 'foo:2' }, + ], + expectedSyncEvents: ['syncing'], + }, + + // 4. OBJECT_SYNC when already SYNCED + + { + description: + 'A complete multi-message OBJECT_SYNC sequence when already SYNCED emits SYNCING and then SYNCED', + channelEvents: [ + { type: 'attached', hasObjects: false }, // to get us to SYNCED + { type: 'objectSync', channelSerial: 'foo:1' }, + { type: 'objectSync', channelSerial: 'foo:2' }, + { type: 'objectSync', channelSerial: 'foo:' }, + ], + expectedSyncEvents: [ + 'syncing', + 'synced', // The initial SYNCED + 'syncing', + 'synced', // From the complete OBJECT_SYNC + ], + }, + + { + description: 'A complete single-message OBJECT_SYNC when already SYNCED emits SYNCING and then SYNCED', + channelEvents: [ + { type: 'attached', hasObjects: false }, // to get us to SYNCED + { type: 'objectSync', channelSerial: 'foo:' }, + ], + expectedSyncEvents: [ + 'syncing', + 'synced', // The initial SYNCED + 'syncing', + 'synced', // From the complete OBJECT_SYNC + ], + }, + + // 5. New sync sequence in the middle of a sync sequence + + { + description: 'A new OBJECT_SYNC sequence in the middle of a sync sequence does not provoke another SYNCING', + channelEvents: [ + { type: 'attached', hasObjects: true }, + { type: 'objectSync', channelSerial: 'foo:1' }, + { type: 'objectSync', channelSerial: 'foo:2' }, + { type: 'objectSync', channelSerial: 'bar:1' }, + ], + expectedSyncEvents: ['syncing'], + }, + ]; + + forScenarios(this, syncEventsScenarios, async function (helper, scenario, clientOptions, channelName) { + 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, channelOptionsWithObjectModes()); + const object = channel.object; + + // Track received sync events + const receivedSyncEvents = []; + + // Subscribe to syncing and synced events + object.on('syncing', () => { + receivedSyncEvents.push('syncing'); + }); + object.on('synced', () => { + receivedSyncEvents.push('synced'); + }); + + // Apply the sequence of channel events described by the scenario + for (const channelEvent of scenario.channelEvents) { + if (channelEvent.type === 'attached') { + await injectAttachedMessage(helper, channel, channelEvent.hasObjects); + } else if (channelEvent.type === 'objectSync') { + await objectsHelper.processObjectStateMessageOnChannel({ + channel, + syncSerial: channelEvent.channelSerial, + }); + } + } + + // Verify the expected sequence of sync events + expect(receivedSyncEvents).to.deep.equal( + scenario.expectedSyncEvents, + 'Check sync events match expected sequence', + ); + }, client); + }); + }); + }); +}); diff --git a/test/realtime/objects.test.js b/test/realtime/objects.test.js deleted file mode 100644 index ebcb821d29..0000000000 --- a/test/realtime/objects.test.js +++ /dev/null @@ -1,5725 +0,0 @@ -'use strict'; - -define(['ably', 'shared_helper', 'chai', 'objects', 'objects_helper'], function ( - Ably, - Helper, - chai, - ObjectsPlugin, - ObjectsHelper, -) { - 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 nextTick = Ably.Realtime.Platform.Config.nextTick; - const gcIntervalOriginal = ObjectsPlugin.Objects._DEFAULTS.gcInterval; - - function RealtimeWithObjects(helper, options) { - return helper.AblyRealtime({ ...options, plugins: { Objects: ObjectsPlugin } }); - } - - function channelOptionsWithObjects(options) { - return { - ...options, - modes: ['OBJECT_SUBSCRIBE', 'OBJECT_PUBLISH'], - }; - } - - function expectInstanceOf(object, className, msg) { - // esbuild changes the name for classes with static method to include an underscore as prefix. - // so LiveMap becomes _LiveMap. we account for it here. - expect(object.constructor.name).to.match(new RegExp(`_?${className}`), msg); - } - - function forScenarios(thisInDescribe, scenarios, testFn) { - for (const scenario of scenarios) { - if (scenario.allTransportsAndProtocols) { - Helper.testOnAllTransportsAndProtocols( - thisInDescribe, - scenario.description, - function (options, channelName) { - return async function () { - const helper = this.test.helper; - await testFn(helper, scenario, options, channelName); - }; - }, - scenario.skip, - scenario.only, - ); - } else { - const itFn = scenario.skip ? it.skip : scenario.only ? it.only : it; - - itFn(scenario.description, async function () { - const helper = this.test.helper; - await testFn(helper, scenario, {}, scenario.description); - }); - } - } - } - - function lexicoTimeserial(seriesId, timestamp, counter, index) { - const paddedTimestamp = timestamp.toString().padStart(14, '0'); - const paddedCounter = counter.toString().padStart(3, '0'); - const paddedIndex = index != null ? index.toString().padStart(3, '0') : undefined; - - // Example: - // - // 01726585978590-001@abcdefghij:001 - // |____________| |_| |________| |_| - // | | | | - // timestamp counter seriesId idx - return `${paddedTimestamp}-${paddedCounter}@${seriesId}` + (paddedIndex ? `:${paddedIndex}` : ''); - } - - async function expectToThrowAsync(fn, errorStr) { - let savedError; - try { - await fn(); - } catch (error) { - expect(error.message).to.have.string(errorStr); - savedError = error; - } - expect(savedError, 'Expected async function to throw an error').to.exist; - - return savedError; - } - - function objectMessageFromValues(values) { - return ObjectsPlugin.ObjectMessage.fromValues(values, Utils, MessageEncoding); - } - - async function waitForMapKeyUpdate(map, key) { - return new Promise((resolve) => { - const { unsubscribe } = map.subscribe(({ update }) => { - if (update[key]) { - unsubscribe(); - resolve(); - } - }); - }); - } - - async function waitForCounterUpdate(counter) { - return new Promise((resolve) => { - const { unsubscribe } = counter.subscribe(() => { - unsubscribe(); - resolve(); - }); - }); - } - - async function waitForObjectOperation(helper, client, waitForAction) { - return new Promise((resolve, reject) => { - helper.recordPrivateApi('call.connectionManager.activeProtocol.getTransport'); - const transport = client.connection.connectionManager.activeProtocol.getTransport(); - const onProtocolMessageOriginal = transport.onProtocolMessage; - - helper.recordPrivateApi('replace.transport.onProtocolMessage'); - transport.onProtocolMessage = function (message) { - try { - helper.recordPrivateApi('call.transport.onProtocolMessage'); - onProtocolMessageOriginal.call(transport, message); - - if (message.action === 19 && message.state[0]?.operation?.action === waitForAction) { - helper.recordPrivateApi('replace.transport.onProtocolMessage'); - transport.onProtocolMessage = onProtocolMessageOriginal; - resolve(); - } - } catch (err) { - reject(err); - } - }; - }); - } - - async function waitForObjectSync(helper, client) { - return new Promise((resolve, reject) => { - helper.recordPrivateApi('call.connectionManager.activeProtocol.getTransport'); - const transport = client.connection.connectionManager.activeProtocol.getTransport(); - const onProtocolMessageOriginal = transport.onProtocolMessage; - - helper.recordPrivateApi('replace.transport.onProtocolMessage'); - transport.onProtocolMessage = function (message) { - try { - helper.recordPrivateApi('call.transport.onProtocolMessage'); - onProtocolMessageOriginal.call(transport, message); - - if (message.action === 20) { - helper.recordPrivateApi('replace.transport.onProtocolMessage'); - transport.onProtocolMessage = onProtocolMessageOriginal; - resolve(); - } - } catch (err) { - reject(err); - } - }; - }); - } - - /** - * The channel with fixture data may not yet be populated by REST API requests made by ObjectsHelper. - * This function waits for a channel to have all keys set. - */ - 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(); - - await Promise.all(expectedKeys.map((key) => (root.get(key) ? undefined : waitForMapKeyUpdate(root, key)))); - } - - describe('realtime/objects', function () { - this.timeout(60 * 1000); - - before(function (done) { - const helper = Helper.forHook(this); - - helper.setupApp(function (err) { - if (err) { - done(err); - return; - } - - new ObjectsHelper(helper) - .initForChannel(objectsFixturesChannel) - .then(done) - .catch((err) => done(err)); - }); - }); - - describe('Realtime without Objects plugin', () => { - /** @nospec */ - it("throws an error when attempting to access the channel's `objects` 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'); - }); - - /** @nospec */ - it(`doesn't break when it receives an OBJECT ProtocolMessage`, async function () { - const helper = this.test.helper; - const objectsHelper = new ObjectsHelper(helper); - const testClient = helper.AblyRealtime(); - - await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const testChannel = testClient.channels.get('channel'); - await testChannel.attach(); - - const receivedMessagePromise = new Promise((resolve) => testChannel.subscribe(resolve)); - - const publishClient = helper.AblyRealtime(); - - await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - // inject OBJECT message that should be ignored and not break anything without the plugin - await objectsHelper.processObjectOperationMessageOnChannel({ - channel: testChannel, - serial: lexicoTimeserial('aaa', 0, 0), - siteCode: 'aaa', - state: [objectsHelper.mapSetOp({ objectId: 'root', key: 'stringKey', data: { string: 'stringValue' } })], - }); - - const publishChannel = publishClient.channels.get('channel'); - await publishChannel.publish(null, 'test'); - - // regular message subscriptions should still work after processing OBJECT_SYNC message without the plugin - await receivedMessagePromise; - }, publishClient); - }, testClient); - }); - - /** @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 testClient = helper.AblyRealtime(); - - await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const testChannel = testClient.channels.get('channel'); - await testChannel.attach(); - - const receivedMessagePromise = new Promise((resolve) => testChannel.subscribe(resolve)); - - const publishClient = helper.AblyRealtime(); - - await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - // inject OBJECT_SYNC message that should be ignored and not break anything without the plugin - await objectsHelper.processObjectStateMessageOnChannel({ - channel: testChannel, - syncSerial: 'serial:', - state: [ - objectsHelper.mapObject({ - objectId: 'root', - siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, - }), - ], - }); - - const publishChannel = publishClient.channels.get('channel'); - await publishChannel.publish(null, 'test'); - - // regular message subscriptions should still work after processing OBJECT_SYNC message without the plugin - await receivedMessagePromise; - }, publishClient); - }, testClient); - }); - }); - - describe('Realtime with Objects plugin', () => { - /** @nospec */ - it("returns Objects 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'); - }); - - /** @nospec */ - it('getRoot() 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(); - - expectInstanceOf(root, 'LiveMap', 'root object should be of LiveMap type'); - }, client); - }); - - /** @nospec */ - it('getRoot() 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(); - - helper.recordPrivateApi('call.LiveObject.getObjectId'); - expect(root.getObjectId()).to.equal('root', 'root object should have an object id "root"'); - }, client); - }); - - /** @nospec */ - it('getRoot() 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(); - - 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 () { - 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(); - - let getRootResolved = false; - getRootPromise.then(() => { - getRootResolved = true; - }); - - // give a chance for getRoot() 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; - - await channel.attach(); - - // should resolve eventually after attach - await getRootPromise; - }, client); - }); - - /** @nospec */ - it('getRoot() 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(); - - let resolvedImmediately = false; - objects.getRoot().then(() => { - resolvedImmediately = true; - }); - - // wait for next tick for getRoot() 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; - }, client); - }); - - /** @nospec */ - it('getRoot() 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(); - - // inject OBJECT_SYNC message to emulate start of a new sequence - await objectsHelper.processObjectStateMessageOnChannel({ - channel, - // have cursor so client awaits for additional OBJECT_SYNC messages - syncSerial: 'serial:cursor', - }); - - let getRootResolved = false; - let root; - objects.getRoot().then((value) => { - getRootResolved = true; - root = value; - }); - - // wait for next tick to check that getRoot() 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; - - // inject final OBJECT_SYNC message - await objectsHelper.processObjectStateMessageOnChannel({ - channel, - // no cursor to indicate the end of OBJECT_SYNC messages - syncSerial: 'serial:', - state: [ - objectsHelper.mapObject({ - objectId: 'root', - siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, - initialEntries: { key: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { number: 1 } } }, - }), - ], - }); - - // wait for next tick for getRoot() 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(root.get('key')).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); - } - } - - const primitiveKeyData = [ - { key: 'stringKey', data: { string: 'stringValue' } }, - { key: 'emptyStringKey', data: { string: '' } }, - { key: 'bytesKey', data: { bytes: 'eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9' } }, - { key: 'emptyBytesKey', data: { bytes: '' } }, - { key: 'maxSafeIntegerKey', data: { number: Number.MAX_SAFE_INTEGER } }, - { key: 'negativeMaxSafeIntegerKey', data: { number: -Number.MAX_SAFE_INTEGER } }, - { key: 'numberKey', data: { number: 1 } }, - { key: 'zeroKey', data: { number: 0 } }, - { key: 'trueKey', data: { boolean: true } }, - { key: 'falseKey', data: { boolean: false } }, - { key: 'objectKey', data: { json: JSON.stringify({ foo: 'bar' }) } }, - { key: 'arrayKey', data: { json: JSON.stringify(['foo', 'bar', 'baz']) } }, - ]; - const primitiveMapsFixtures = [ - { name: 'emptyMap' }, - { - name: 'valuesMap', - entries: primitiveKeyData.reduce((acc, v) => { - acc[v.key] = { data: v.data }; - return acc; - }, {}), - restData: primitiveKeyData.reduce((acc, v) => { - acc[v.key] = v.data; - return acc; - }, {}), - }, - ]; - const countersFixtures = [ - { name: 'emptyCounter' }, - { name: 'zeroCounter', count: 0 }, - { name: 'valueCounter', count: 10 }, - { name: 'negativeValueCounter', count: -10 }, - { name: 'maxSafeIntegerCounter', count: Number.MAX_SAFE_INTEGER }, - { name: 'negativeMaxSafeIntegerCounter', count: -Number.MAX_SAFE_INTEGER }, - ]; - - const objectSyncSequenceScenarios = [ - { - allTransportsAndProtocols: true, - description: 'OBJECT_SYNC sequence builds object tree on channel attachment', - action: async (ctx) => { - const { client } = ctx; - - await waitFixtureChannelIsReady(client); - - const channel = client.channels.get(objectsFixturesChannel, channelOptionsWithObjects()); - const objects = channel.objects; - - await channel.attach(); - const root = await objects.getRoot(); - - 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.size()).to.equal(rootKeysCount, 'Check root has correct number of keys'); - - counterKeys.forEach((key) => { - const counter = root.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`); - }); - - mapKeys.forEach((key) => { - const map = root.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`); - }); - - const valuesMap = root.get('valuesMap'); - const valueMapKeys = [ - 'stringKey', - 'emptyStringKey', - 'bytesKey', - 'emptyBytesKey', - 'maxSafeIntegerKey', - 'negativeMaxSafeIntegerKey', - 'numberKey', - 'zeroKey', - 'trueKey', - 'falseKey', - 'objectKey', - 'arrayKey', - 'mapKey', - ]; - 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; - }); - }, - }, - - { - allTransportsAndProtocols: true, - description: 'OBJECT_SYNC sequence builds object tree with all operations applied', - action: async (ctx) => { - const { root, objects, helper, clientOptions, channelName } = ctx; - - const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), - ]); - - // MAP_CREATE - const map = await objects.createMap({ shouldStay: 'foo', shouldDelete: 'bar' }); - // COUNTER_CREATE - const counter = await objects.createCounter(1); - - await Promise.all([root.set('map', map), root.set('counter', counter), objectsCreatedPromise]); - - const operationsAppliedPromise = Promise.all([ - waitForMapKeyUpdate(map, 'anotherKey'), - waitForMapKeyUpdate(map, 'shouldDelete'), - waitForCounterUpdate(counter), - ]); - - await Promise.all([ - // MAP_SET - map.set('anotherKey', 'baz'), - // MAP_REMOVE - map.remove('shouldDelete'), - // COUNTER_INC - counter.increment(10), - operationsAppliedPromise, - ]); - - // create a new client and check it syncs with the aggregated data - const client2 = RealtimeWithObjects(helper, clientOptions); - - await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const channel2 = client2.channels.get(channelName, channelOptionsWithObjects()); - const objects2 = channel2.objects; - - await channel2.attach(); - const root2 = await objects2.getRoot(); - - expect(root2.get('counter'), 'Check counter exists').to.exist; - expect(root2.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( - 'foo', - 'Check map has correct value for "shouldStay" key', - ); - expect(root2.get('map').get('anotherKey')).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; - }, client2); - }, - }, - - { - description: 'OBJECT_SYNC sequence does not change references to existing objects', - action: async (ctx) => { - const { root, objects, helper, channel } = ctx; - - const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), - ]); - - const map = await objects.createMap(); - const counter = await objects.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 - const objectSyncPromise = waitForObjectSync(helper, channel.client); - await channel.attach(); - await objectSyncPromise; - - const newRootRef = await channel.objects.getRoot(); - const newMapRef = newRootRef.get('map'); - const newCounterRef = newRootRef.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'); - }, - }, - - { - allTransportsAndProtocols: true, - description: 'LiveCounter is initialized with initial value from OBJECT_SYNC sequence', - action: async (ctx) => { - const { client } = ctx; - - await waitFixtureChannelIsReady(client); - - const channel = client.channels.get(objectsFixturesChannel, channelOptionsWithObjects()); - const objects = channel.objects; - - await channel.attach(); - const root = await objects.getRoot(); - - const counters = [ - { key: 'emptyCounter', value: 0 }, - { key: 'initialValueCounter', value: 10 }, - { key: 'referencedCounter', value: 20 }, - ]; - - counters.forEach((x) => { - const counter = root.get(x.key); - expect(counter.value()).to.equal(x.value, `Check counter at key="${x.key}" in root has correct value`); - }); - }, - }, - - { - allTransportsAndProtocols: true, - description: 'LiveMap is initialized with initial value from OBJECT_SYNC sequence', - action: async (ctx) => { - const { helper, client } = ctx; - - await waitFixtureChannelIsReady(client); - - const channel = client.channels.get(objectsFixturesChannel, channelOptionsWithObjects()); - const objects = channel.objects; - - await channel.attach(); - const root = await objects.getRoot(); - - const emptyMap = root.get('emptyMap'); - expect(emptyMap.size()).to.equal(0, 'Check empty map in root has no keys'); - - const referencedMap = root.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'); - 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'); - helper.recordPrivateApi('call.BufferUtils.base64Decode'); - helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); - expect( - BufferUtils.areBuffersEqual( - valuesMap.get('bytesKey'), - BufferUtils.base64Decode('eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9'), - ), - 'Check values map has correct bytes value key', - ).to.be.true; - helper.recordPrivateApi('call.BufferUtils.base64Decode'); - helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); - expect( - BufferUtils.areBuffersEqual(valuesMap.get('emptyBytesKey'), BufferUtils.base64Decode('')), - 'Check values map has correct empty bytes value key', - ).to.be.true; - expect(valuesMap.get('maxSafeIntegerKey')).to.equal( - Number.MAX_SAFE_INTEGER, - 'Check values map has correct maxSafeIntegerKey value', - ); - expect(valuesMap.get('negativeMaxSafeIntegerKey')).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( - { foo: 'bar' }, - `Check values map has correct objectKey value`, - ); - expect(valuesMap.get('arrayKey')).to.deep.equal( - ['foo', 'bar', 'baz'], - `Check values map has correct arrayKey value`, - ); - - const mapFromValuesMap = valuesMap.get('mapKey'); - expect(mapFromValuesMap.size()).to.equal(1, 'Check nested map has correct number of keys'); - }, - }, - - { - 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()); - const objects = channel.objects; - - await channel.attach(); - const root = await objects.getRoot(); - - 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 mapId = objectsHelper.fakeMapObjectId(); - const counterId = objectsHelper.fakeCounterObjectId(); - await objectsHelper.processObjectStateMessageOnChannel({ - channel, - syncSerial: 'serial:', // empty serial so sync sequence ends immediately - // add object states with tombstone=true - state: [ - objectsHelper.mapObject({ - objectId: mapId, - siteTimeserials: { - aaa: lexicoTimeserial('aaa', 0, 0), - }, - tombstone: true, - initialEntries: {}, - }), - objectsHelper.counterObject({ - objectId: counterId, - siteTimeserials: { - aaa: lexicoTimeserial('aaa', 0, 0), - }, - tombstone: true, - initialCount: 1, - }), - objectsHelper.mapObject({ - objectId: 'root', - siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, - initialEntries: { - map: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: mapId } }, - counter: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: counterId } }, - foo: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { string: 'bar' } }, - }, - }), - ], - }); - - expect( - root.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'), - '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; - }, - }, - - { - 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 counterCreatedPromise = waitForMapKeyUpdate(root, 'counter'); - const { objectId: counterId } = await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'counter', - createOp: objectsHelper.counterCreateRestOp({ number: 1 }), - }); - await counterCreatedPromise; - - expect( - root.get('counter'), - 'Check counter exists on root before OBJECT_SYNC sequence with "tombstone=true"', - ).to.exist; - - // inject an OBJECT_SYNC message where a counter is now tombstoned - await objectsHelper.processObjectStateMessageOnChannel({ - channel, - syncSerial: 'serial:', // empty serial so sync sequence ends immediately - state: [ - objectsHelper.counterObject({ - objectId: counterId, - siteTimeserials: { - aaa: lexicoTimeserial('aaa', 0, 0), - }, - tombstone: true, - initialCount: 1, - }), - objectsHelper.mapObject({ - objectId: 'root', - siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, - initialEntries: { - counter: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: counterId } }, - foo: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { string: 'bar' } }, - }, - }), - ], - }); - - expect( - root.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; - }, - }, - - { - allTransportsAndProtocols: true, - 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 counterCreatedPromise = waitForMapKeyUpdate(root, 'counter'); - const { objectId: counterId } = await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'counter', - createOp: objectsHelper.counterCreateRestOp({ number: 1 }), - }); - await counterCreatedPromise; - - const counterSubPromise = new Promise((resolve, reject) => - root.get('counter').subscribe((update) => { - 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"', - ); - resolve(); - } catch (error) { - reject(error); - } - }), - ); - - // inject an OBJECT_SYNC message where a counter is now tombstoned - await objectsHelper.processObjectStateMessageOnChannel({ - channel, - syncSerial: 'serial:', // empty serial so sync sequence ends immediately - state: [ - objectsHelper.counterObject({ - objectId: counterId, - siteTimeserials: { - aaa: lexicoTimeserial('aaa', 0, 0), - }, - tombstone: true, - initialCount: 1, - }), - objectsHelper.mapObject({ - objectId: 'root', - siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, - initialEntries: { - counter: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: counterId } }, - }, - }), - ], - }); - - await counterSubPromise; - }, - }, - - { - description: - 'OBJECT_SYNC sequence with "tombstone=true" for an object sets "tombstoneAt" from "serialTimestamp"', - action: async (ctx) => { - const { helper, objectsHelper, channel, objects } = ctx; - - const counterId = objectsHelper.fakeCounterObjectId(); - const serialTimestamp = 1234567890; - await objectsHelper.processObjectStateMessageOnChannel({ - channel, - syncSerial: 'serial:', // empty serial so sync sequence ends immediately - serialTimestamp, - state: [ - objectsHelper.counterObject({ - objectId: counterId, - siteTimeserials: { - aaa: lexicoTimeserial('aaa', 0, 0), - }, - tombstone: true, - initialCount: 1, - }), - objectsHelper.mapObject({ - objectId: 'root', - siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, - initialEntries: { - counter: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: counterId } }, - }, - }), - ], - }); - - helper.recordPrivateApi('call.Objects._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'); - expect(obj.tombstonedAt()).to.equal( - serialTimestamp, - `Check object's "tombstonedAt" value is set to "serialTimestamp" from OBJECT_SYNC sequence`, - ); - }, - }, - - { - 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 tsBeforeMsg = Date.now(); - const counterId = objectsHelper.fakeCounterObjectId(); - await objectsHelper.processObjectStateMessageOnChannel({ - // don't provide serialTimestamp - channel, - syncSerial: 'serial:', // empty serial so sync sequence ends immediately - state: [ - objectsHelper.counterObject({ - objectId: counterId, - siteTimeserials: { - aaa: lexicoTimeserial('aaa', 0, 0), - }, - tombstone: true, - initialCount: 1, - }), - objectsHelper.mapObject({ - objectId: 'root', - siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, - initialEntries: { - counter: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: counterId } }, - }, - }), - ], - }); - const tsAfterMsg = Date.now(); - - helper.recordPrivateApi('call.Objects._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'); - expect( - tsBeforeMsg <= obj.tombstonedAt() <= tsAfterMsg, - `Check object's "tombstonedAt" value is set using local clock if no "serialTimestamp" provided`, - ).to.be.true; - }, - }, - - { - 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 serialTimestamp = 1234567890; - await objectsHelper.processObjectStateMessageOnChannel({ - channel, - syncSerial: 'serial:', // empty serial so sync sequence ends immediately - state: [ - objectsHelper.mapObject({ - objectId: 'root', - siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, - initialEntries: { - foo: { - timeserial: lexicoTimeserial('aaa', 0, 0), - data: { string: 'bar' }, - tombstone: true, - serialTimestamp, - }, - }, - }), - ], - }); - - helper.recordPrivateApi('read.LiveMap._dataRef.data'); - const mapEntry = root._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', - ).to.exist; - expect(mapEntry.tombstonedAt).to.equal( - serialTimestamp, - `Check map entry's "tombstonedAt" value is set to "serialTimestamp" from OBJECT_SYNC sequence`, - ); - }, - }, - - { - 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 tsBeforeMsg = Date.now(); - await objectsHelper.processObjectStateMessageOnChannel({ - channel, - syncSerial: 'serial:', // empty serial so sync sequence ends immediately - state: [ - objectsHelper.mapObject({ - objectId: 'root', - siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, - initialEntries: { - foo: { - timeserial: lexicoTimeserial('aaa', 0, 0), - data: { string: 'bar' }, - tombstone: true, - // don't provide serialTimestamp - }, - }, - }), - ], - }); - const tsAfterMsg = Date.now(); - - helper.recordPrivateApi('read.LiveMap._dataRef.data'); - const mapEntry = root._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', - ).to.exist; - expect( - tsBeforeMsg <= mapEntry.tombstonedAt <= tsAfterMsg, - `Check map entry's "tombstonedAt" value is set using local clock if no "serialTimestamp" provided`, - ).to.be.true; - }, - }, - ]; - - const applyOperationsScenarios = [ - { - allTransportsAndProtocols: true, - description: 'can apply MAP_CREATE with primitives object operation messages', - action: async (ctx) => { - const { root, objectsHelper, channelName, helper } = 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. - // however, in this test we put heavy focus on the data that is being created as the result of the MAP_CREATE op. - - // 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; - }); - - const mapsCreatedPromise = Promise.all(primitiveMapsFixtures.map((x) => waitForMapKeyUpdate(root, x.name))); - // create new maps and set on root - await Promise.all( - primitiveMapsFixtures.map((fixture) => - objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: fixture.name, - createOp: objectsHelper.mapCreateRestOp({ data: fixture.restData }), - }), - ), - ); - await mapsCreatedPromise; - - // check created maps - primitiveMapsFixtures.forEach((fixture) => { - const mapKey = fixture.name; - const mapObj = root.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`); - - // check primitive maps have correct values - expect(mapObj.size()).to.equal( - Object.keys(fixture.entries ?? {}).length, - `Check map "${mapKey}" has correct number of keys`, - ); - - Object.entries(fixture.entries ?? {}).forEach(([key, keyData]) => { - checkKeyDataOnMap({ - helper, - key, - keyData, - mapObj, - msg: `Check map "${mapKey}" has correct value for "${key}" key`, - }); - }); - }); - }, - }, - - { - allTransportsAndProtocols: true, - description: 'can apply MAP_CREATE with object ids object operation messages', - action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; - const withReferencesMapKey = 'withReferencesMap'; - - // 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. - // however, in this test we put heavy focus on the data that is being created as the result of the MAP_CREATE op. - - // check map does not exist on root - expect( - root.get(withReferencesMapKey), - `Check "${withReferencesMapKey}" key doesn't exist on root before applying MAP_CREATE ops`, - ).to.not.exist; - - const mapCreatedPromise = waitForMapKeyUpdate(root, withReferencesMapKey); - // create map with references. need to create referenced objects first to obtain their object ids - const { objectId: referencedMapObjectId } = await objectsHelper.operationRequest( - channelName, - objectsHelper.mapCreateRestOp({ data: { stringKey: { string: 'stringValue' } } }), - ); - const { objectId: referencedCounterObjectId } = await objectsHelper.operationRequest( - channelName, - objectsHelper.counterCreateRestOp({ number: 1 }), - ); - await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: withReferencesMapKey, - createOp: objectsHelper.mapCreateRestOp({ - data: { - mapReference: { objectId: referencedMapObjectId }, - counterReference: { objectId: referencedCounterObjectId }, - }, - }), - }); - await mapCreatedPromise; - - // check map with references exist on root - const withReferencesMap = root.get(withReferencesMapKey); - expect(withReferencesMap, `Check map at "${withReferencesMapKey}" key in root exists`).to.exist; - expectInstanceOf( - withReferencesMap, - 'LiveMap', - `Check map at "${withReferencesMapKey}" key in root is of type LiveMap`, - ); - - // check map with references has correct values - expect(withReferencesMap.size()).to.equal( - 2, - `Check map "${withReferencesMapKey}" has correct number of keys`, - ); - - const referencedCounter = withReferencesMap.get('counterReference'); - const referencedMap = withReferencesMap.get('mapReference'); - - expect(referencedCounter, `Check counter at "counterReference" exists`).to.exist; - expectInstanceOf( - referencedCounter, - '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`); - - expect(referencedMap.size()).to.equal(1, 'Check map at "mapReference" key has correct number of keys'); - expect(referencedMap.get('stringKey')).to.equal( - 'stringValue', - 'Check map at "mapReference" key has correct "stringKey" value', - ); - }, - }, - - { - 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; - - // need to use multiple maps as MAP_CREATE op can only be applied once to a map object - const mapIds = [ - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - ]; - await Promise.all( - mapIds.map(async (mapId, i) => { - // send a MAP_SET op first to create a zero-value map with forged site timeserials vector (from the op), and set it on a root. - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('bbb', 1, 0), - siteCode: 'bbb', - state: [objectsHelper.mapSetOp({ objectId: mapId, key: 'foo', data: { string: 'bar' } })], - }); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', i, 0), - siteCode: 'aaa', - state: [objectsHelper.mapSetOp({ objectId: 'root', key: mapId, data: { objectId: mapId } })], - }); - }), - ); - - // inject operations with various timeserial values - for (const [i, { serial, siteCode }] of [ - { serial: lexicoTimeserial('bbb', 0, 0), siteCode: 'bbb' }, // existing site, earlier CGO, not applied - { serial: lexicoTimeserial('bbb', 1, 0), siteCode: 'bbb' }, // existing site, same CGO, not applied - { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // existing site, later CGO, applied - { serial: lexicoTimeserial('aaa', 0, 0), siteCode: 'aaa' }, // different site, earlier CGO, applied - { serial: lexicoTimeserial('ccc', 9, 0), siteCode: 'ccc' }, // different site, later CGO, applied - ].entries()) { - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial, - siteCode, - state: [ - objectsHelper.mapCreateOp({ - objectId: mapIds[i], - entries: { - baz: { timeserial: serial, data: { string: 'qux' } }, - }, - }), - ], - }); - } - - // check only operations with correct timeserials were applied - const expectedMapValues = [ - { foo: 'bar' }, - { foo: 'bar' }, - { foo: 'bar', baz: 'qux' }, // applied MAP_CREATE - { foo: 'bar', baz: 'qux' }, // applied MAP_CREATE - { foo: 'bar', baz: 'qux' }, // applied MAP_CREATE - ]; - - for (const [i, mapId] of mapIds.entries()) { - const expectedMapValue = expectedMapValues[i]; - const expectedKeysCount = Object.keys(expectedMapValue).length; - - expect(root.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( - value, - `Check map #${i + 1} has expected value for "${key}" key after MAP_CREATE ops`, - ); - }); - } - }, - }, - - { - allTransportsAndProtocols: true, - description: 'can apply MAP_SET with primitives object operation messages', - action: async (ctx) => { - const { root, objectsHelper, channelName, helper } = ctx; - - // check root is empty before ops - primitiveKeyData.forEach((keyData) => { - expect( - root.get(keyData.key), - `Check "${keyData.key}" key doesn't exist on root before applying MAP_SET ops`, - ).to.not.exist; - }); - - const keysUpdatedPromise = Promise.all(primitiveKeyData.map((x) => waitForMapKeyUpdate(root, x.key))); - // apply MAP_SET ops - await Promise.all( - primitiveKeyData.map((keyData) => - objectsHelper.operationRequest( - channelName, - objectsHelper.mapSetRestOp({ - objectId: 'root', - key: keyData.key, - value: keyData.data, - }), - ), - ), - ); - await keysUpdatedPromise; - - // check everything is applied correctly - primitiveKeyData.forEach((keyData) => { - checkKeyDataOnMap({ - helper, - key: keyData.key, - keyData, - mapObj: root, - msg: `Check root has correct value for "${keyData.key}" key after MAP_SET op`, - }); - }); - }, - }, - - { - allTransportsAndProtocols: true, - description: 'can apply MAP_SET with object ids object operation messages', - action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; - - // check no object ids are set on root - expect( - root.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; - - const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'keyToCounter'), - waitForMapKeyUpdate(root, 'keyToMap'), - ]); - // create new objects and set on root - await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'keyToCounter', - createOp: objectsHelper.counterCreateRestOp({ number: 1 }), - }); - - await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'keyToMap', - createOp: objectsHelper.mapCreateRestOp({ - data: { - stringKey: { string: 'stringValue' }, - }, - }), - }); - 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'); - - expect(counter, 'Check counter at "keyToCounter" key in root exists').to.exist; - expectInstanceOf( - counter, - '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'); - expect(map.size()).to.equal(1, 'Check map at "keyToMap" key in root has correct number of keys'); - expect(map.get('stringKey')).to.equal( - 'stringValue', - 'Check map at "keyToMap" key in root has correct "stringKey" value', - ); - }, - }, - - { - 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; - - // create new map and set it on a root with forged timeserials - const mapId = objectsHelper.fakeMapObjectId(); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('bbb', 1, 0), - siteCode: 'bbb', - state: [ - objectsHelper.mapCreateOp({ - objectId: mapId, - entries: { - foo1: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, - foo2: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, - foo3: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, - foo4: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, - foo5: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, - foo6: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, - }, - }), - ], - }); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 0, 0), - siteCode: 'aaa', - state: [objectsHelper.mapSetOp({ objectId: 'root', key: 'map', data: { objectId: mapId } })], - }); - - // inject operations with various timeserial values - for (const [i, { serial, siteCode }] of [ - { serial: lexicoTimeserial('bbb', 0, 0), siteCode: 'bbb' }, // existing site, earlier site CGO, not applied - { serial: lexicoTimeserial('bbb', 1, 0), siteCode: 'bbb' }, // existing site, same site CGO, not applied - { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // existing site, later site CGO, applied, site timeserials updated - { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // existing site, same site CGO (updated from last op), not applied - { serial: lexicoTimeserial('aaa', 0, 0), siteCode: 'aaa' }, // different site, earlier entry CGO, not applied - { serial: lexicoTimeserial('ccc', 9, 0), siteCode: 'ccc' }, // different site, later entry CGO, applied - ].entries()) { - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial, - siteCode, - state: [objectsHelper.mapSetOp({ objectId: mapId, key: `foo${i + 1}`, data: { string: 'baz' } })], - }); - } - - // check only operations with correct timeserials were applied - const expectedMapKeys = [ - { key: 'foo1', value: 'bar' }, - { key: 'foo2', value: 'bar' }, - { key: 'foo3', value: 'baz' }, // updated - { key: 'foo4', value: 'bar' }, - { key: 'foo5', value: 'bar' }, - { key: 'foo6', value: 'baz' }, // updated - ]; - - expectedMapKeys.forEach(({ key, value }) => { - expect(root.get('map').get(key)).to.equal( - value, - `Check "${key}" key on map has expected value after MAP_SET ops`, - ); - }); - }, - }, - - { - allTransportsAndProtocols: true, - description: 'can apply MAP_REMOVE object operation messages', - action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; - const mapKey = 'map'; - - const mapCreatedPromise = waitForMapKeyUpdate(root, mapKey); - // create new map and set on root - const { objectId: mapObjectId } = await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: mapKey, - createOp: objectsHelper.mapCreateRestOp({ - data: { - shouldStay: { string: 'foo' }, - shouldDelete: { string: 'bar' }, - }, - }), - }); - await mapCreatedPromise; - - const map = root.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( - 'foo', - `Check map at "${mapKey}" key in root has correct "shouldStay" value before MAP_REMOVE`, - ); - expect(map.get('shouldDelete')).to.equal( - 'bar', - `Check map at "${mapKey}" key in root has correct "shouldDelete" value before MAP_REMOVE`, - ); - - const keyRemovedPromise = waitForMapKeyUpdate(map, 'shouldDelete'); - // send MAP_REMOVE op - await objectsHelper.operationRequest( - channelName, - objectsHelper.mapRemoveRestOp({ - objectId: mapObjectId, - key: 'shouldDelete', - }), - ); - await keyRemovedPromise; - - // check map has correct keys after MAP_REMOVE ops - expect(map.size()).to.equal( - 1, - `Check map at "${mapKey}" key in root has correct number of keys after MAP_REMOVE`, - ); - expect(map.get('shouldStay')).to.equal( - 'foo', - `Check map at "${mapKey}" key in root has correct "shouldStay" value after MAP_REMOVE`, - ); - expect( - map.get('shouldDelete'), - `Check map at "${mapKey}" key in root has no "shouldDelete" key after MAP_REMOVE`, - ).to.not.exist; - }, - }, - - { - 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; - - // create new map and set it on a root with forged timeserials - const mapId = objectsHelper.fakeMapObjectId(); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('bbb', 1, 0), - siteCode: 'bbb', - state: [ - objectsHelper.mapCreateOp({ - objectId: mapId, - entries: { - foo1: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, - foo2: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, - foo3: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, - foo4: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, - foo5: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, - foo6: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, - }, - }), - ], - }); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 0, 0), - siteCode: 'aaa', - state: [objectsHelper.mapSetOp({ objectId: 'root', key: 'map', data: { objectId: mapId } })], - }); - - // inject operations with various timeserial values - for (const [i, { serial, siteCode }] of [ - { serial: lexicoTimeserial('bbb', 0, 0), siteCode: 'bbb' }, // existing site, earlier site CGO, not applied - { serial: lexicoTimeserial('bbb', 1, 0), siteCode: 'bbb' }, // existing site, same site CGO, not applied - { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // existing site, later site CGO, applied, site timeserials updated - { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // existing site, same site CGO (updated from last op), not applied - { serial: lexicoTimeserial('aaa', 0, 0), siteCode: 'aaa' }, // different site, earlier entry CGO, not applied - { serial: lexicoTimeserial('ccc', 9, 0), siteCode: 'ccc' }, // different site, later entry CGO, applied - ].entries()) { - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial, - siteCode, - state: [objectsHelper.mapRemoveOp({ objectId: mapId, key: `foo${i + 1}` })], - }); - } - - // check only operations with correct timeserials were applied - const expectedMapKeys = [ - { key: 'foo1', exists: true }, - { key: 'foo2', exists: true }, - { key: 'foo3', exists: false }, // removed - { key: 'foo4', exists: true }, - { key: 'foo5', exists: true }, - { key: 'foo6', exists: false }, // removed - ]; - - 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; - } else { - expect(root.get('map').get(key), `Check "${key}" key on map does not exist after MAP_REMOVE ops`).to.not - .exist; - } - }); - }, - }, - - { - description: 'MAP_REMOVE for a map entry sets "tombstoneAt" from "serialTimestamp"', - action: async (ctx) => { - const { helper, channel, root, objectsHelper } = ctx; - - const serialTimestamp = 1234567890; - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 0, 0), - serialTimestamp, - siteCode: 'aaa', - state: [objectsHelper.mapRemoveOp({ objectId: 'root', key: 'foo' })], - }); - - helper.recordPrivateApi('read.LiveMap._dataRef.data'); - const mapEntry = root._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( - serialTimestamp, - `Check map entry's "tombstonedAt" value is set to "serialTimestamp" from MAP_REMOVE`, - ); - }, - }, - - { - 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 tsBeforeMsg = Date.now(); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 0, 0), - // don't provide serialTimestamp - siteCode: 'aaa', - state: [objectsHelper.mapRemoveOp({ objectId: 'root', key: 'foo' })], - }); - const tsAfterMsg = Date.now(); - - helper.recordPrivateApi('read.LiveMap._dataRef.data'); - const mapEntry = root._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( - tsBeforeMsg <= mapEntry.tombstonedAt <= tsAfterMsg, - `Check map entry's "tombstonedAt" value is set using local clock if no "serialTimestamp" provided`, - ).to.be.true; - }, - }, - - { - allTransportsAndProtocols: true, - description: 'can apply COUNTER_CREATE object operation messages', - action: async (ctx) => { - const { root, objectsHelper, channelName } = 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. - // however, in this test we put heavy focus on the data that is being created as the result of the COUNTER_CREATE op. - - // 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; - }); - - const countersCreatedPromise = Promise.all(countersFixtures.map((x) => waitForMapKeyUpdate(root, x.name))); - // create new counters and set on root - await Promise.all( - countersFixtures.map((fixture) => - objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: fixture.name, - createOp: objectsHelper.counterCreateRestOp({ number: fixture.count }), - }), - ), - ); - await countersCreatedPromise; - - // check created counters - countersFixtures.forEach((fixture) => { - const key = fixture.name; - const counterObj = root.get(key); - - // check all counters exist on root - expect(counterObj, `Check counter at "${key}" key in root exists`).to.exist; - expectInstanceOf( - counterObj, - 'LiveCounter', - `Check counter at "${key}" key in root is of type LiveCounter`, - ); - - // check counters have correct values - expect(counterObj.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`, - ); - }); - }, - }, - - { - 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; - - // need to use multiple counters as COUNTER_CREATE op can only be applied once to a counter object - const counterIds = [ - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - ]; - await Promise.all( - counterIds.map(async (counterId, i) => { - // send a COUNTER_INC op first to create a zero-value counter with forged site timeserials vector (from the op), and set it on a root. - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('bbb', 1, 0), - siteCode: 'bbb', - state: [objectsHelper.counterIncOp({ objectId: counterId, amount: 1 })], - }); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', i, 0), - siteCode: 'aaa', - state: [objectsHelper.mapSetOp({ objectId: 'root', key: counterId, data: { objectId: counterId } })], - }); - }), - ); - - // inject operations with various timeserial values - for (const [i, { serial, siteCode }] of [ - { serial: lexicoTimeserial('bbb', 0, 0), siteCode: 'bbb' }, // existing site, earlier CGO, not applied - { serial: lexicoTimeserial('bbb', 1, 0), siteCode: 'bbb' }, // existing site, same CGO, not applied - { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // existing site, later CGO, applied - { serial: lexicoTimeserial('aaa', 0, 0), siteCode: 'aaa' }, // different site, earlier CGO, applied - { serial: lexicoTimeserial('ccc', 9, 0), siteCode: 'ccc' }, // different site, later CGO, applied - ].entries()) { - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial, - siteCode, - state: [objectsHelper.counterCreateOp({ objectId: counterIds[i], count: 10 })], - }); - } - - // check only operations with correct timeserials were applied - const expectedCounterValues = [ - 1, - 1, - 11, // applied COUNTER_CREATE - 11, // applied COUNTER_CREATE - 11, // applied COUNTER_CREATE - ]; - - for (const [i, counterId] of counterIds.entries()) { - const expectedValue = expectedCounterValues[i]; - - expect(root.get(counterId).value()).to.equal( - expectedValue, - `Check counter #${i + 1} has expected value after COUNTER_CREATE ops`, - ); - } - }, - }, - - { - allTransportsAndProtocols: true, - description: 'can apply COUNTER_INC object operation messages', - action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; - const counterKey = 'counter'; - let expectedCounterValue = 0; - - const counterCreated = waitForMapKeyUpdate(root, counterKey); - // create new counter and set on root - const { objectId: counterObjectId } = await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: counterKey, - createOp: objectsHelper.counterCreateRestOp({ number: expectedCounterValue }), - }); - await counterCreated; - - const counter = root.get(counterKey); - // check counter has expected value before COUNTER_INC - expect(counter.value()).to.equal( - expectedCounterValue, - `Check counter at "${counterKey}" key in root has correct value before COUNTER_INC`, - ); - - const increments = [ - 1, // value=1 - 10, // value=11 - 100, // value=111 - 1000000, // value=1000111 - -1000111, // value=0 - -1, // value=-1 - -10, // value=-11 - -100, // value=-111 - -1000000, // value=-1000111 - 1000111, // value=0 - Number.MAX_SAFE_INTEGER, // value=9007199254740991 - // do next decrements in 2 steps as opposed to multiplying by -2 to prevent overflow - -Number.MAX_SAFE_INTEGER, // value=0 - -Number.MAX_SAFE_INTEGER, // value=-9007199254740991 - ]; - - // send increments one at a time and check expected value - for (let i = 0; i < increments.length; i++) { - const increment = increments[i]; - expectedCounterValue += increment; - - const counterUpdatedPromise = waitForCounterUpdate(counter); - await objectsHelper.operationRequest( - channelName, - objectsHelper.counterIncRestOp({ - objectId: counterObjectId, - number: increment, - }), - ); - await counterUpdatedPromise; - - expect(counter.value()).to.equal( - expectedCounterValue, - `Check counter at "${counterKey}" key in root has correct value after ${i + 1} COUNTER_INC ops`, - ); - } - }, - }, - - { - 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; - - // create new counter and set it on a root with forged timeserials - const counterId = objectsHelper.fakeCounterObjectId(); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('bbb', 1, 0), - siteCode: 'bbb', - state: [objectsHelper.counterCreateOp({ objectId: counterId, count: 1 })], - }); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 0, 0), - siteCode: 'aaa', - state: [objectsHelper.mapSetOp({ objectId: 'root', key: 'counter', data: { objectId: counterId } })], - }); - - // inject operations with various timeserial values - for (const [i, { serial, siteCode }] of [ - { serial: lexicoTimeserial('bbb', 0, 0), siteCode: 'bbb' }, // +10 existing site, earlier CGO, not applied - { serial: lexicoTimeserial('bbb', 1, 0), siteCode: 'bbb' }, // +100 existing site, same CGO, not applied - { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // +1000 existing site, later CGO, applied, site timeserials updated - { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // +10000 existing site, same CGO (updated from last op), not applied - { serial: lexicoTimeserial('aaa', 0, 0), siteCode: 'aaa' }, // +100000 different site, earlier CGO, applied - { serial: lexicoTimeserial('ccc', 9, 0), siteCode: 'ccc' }, // +1000000 different site, later CGO, applied - ].entries()) { - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial, - siteCode, - state: [objectsHelper.counterIncOp({ objectId: counterId, amount: Math.pow(10, i + 1) })], - }); - } - - // check only operations with correct timeserials were applied - expect(root.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`, - ); - }, - }, - - { - description: 'can apply OBJECT_DELETE object operation messages', - action: async (ctx) => { - const { root, objectsHelper, channelName, channel } = ctx; - - const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'map'), - waitForMapKeyUpdate(root, 'counter'), - ]); - // create initial objects and set on root - const { objectId: mapObjectId } = await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'map', - createOp: objectsHelper.mapCreateRestOp(), - }); - const { objectId: counterObjectId } = await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'counter', - createOp: objectsHelper.counterCreateRestOp(), - }); - 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; - - // inject OBJECT_DELETE - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 0, 0), - siteCode: 'aaa', - state: [objectsHelper.objectDeleteOp({ objectId: mapObjectId })], - }); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 1, 0), - siteCode: 'aaa', - 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; - }, - }, - - { - description: 'OBJECT_DELETE for unknown object id creates zero-value tombstoned object', - action: async (ctx) => { - const { root, objectsHelper, channel } = ctx; - - const counterId = objectsHelper.fakeCounterObjectId(); - // inject OBJECT_DELETE. should create a zero-value tombstoned object which can't be modified - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 0, 0), - siteCode: 'aaa', - state: [objectsHelper.objectDeleteOp({ objectId: counterId })], - }); - - // try to create and set tombstoned object on root - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('bbb', 0, 0), - siteCode: 'bbb', - state: [objectsHelper.counterCreateOp({ objectId: counterId })], - }); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('bbb', 1, 0), - siteCode: 'bbb', - state: [objectsHelper.mapSetOp({ objectId: 'root', key: 'counter', data: { objectId: counterId } })], - }); - - expect(root.get('counter'), 'Check counter is not accessible on root').to.not.exist; - }, - }, - - { - 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; - - // need to use multiple objects as OBJECT_DELETE op can only be applied once to an object - const counterIds = [ - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - ]; - await Promise.all( - counterIds.map(async (counterId, i) => { - // create objects and set them on root with forged timeserials - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('bbb', 1, 0), - siteCode: 'bbb', - state: [objectsHelper.counterCreateOp({ objectId: counterId })], - }); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', i, 0), - siteCode: 'aaa', - state: [objectsHelper.mapSetOp({ objectId: 'root', key: counterId, data: { objectId: counterId } })], - }); - }), - ); - - // inject OBJECT_DELETE operations with various timeserial values - for (const [i, { serial, siteCode }] of [ - { serial: lexicoTimeserial('bbb', 0, 0), siteCode: 'bbb' }, // existing site, earlier CGO, not applied - { serial: lexicoTimeserial('bbb', 1, 0), siteCode: 'bbb' }, // existing site, same CGO, not applied - { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // existing site, later CGO, applied - { serial: lexicoTimeserial('aaa', 0, 0), siteCode: 'aaa' }, // different site, earlier CGO, applied - { serial: lexicoTimeserial('ccc', 9, 0), siteCode: 'ccc' }, // different site, later CGO, applied - ].entries()) { - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial, - siteCode, - state: [objectsHelper.objectDeleteOp({ objectId: counterIds[i] })], - }); - } - - // check only operations with correct timeserials were applied - const expectedCounters = [ - { exists: true }, - { exists: true }, - { exists: false }, // OBJECT_DELETE applied - { exists: false }, // OBJECT_DELETE applied - { exists: false }, // OBJECT_DELETE applied - ]; - - for (const [i, counterId] of counterIds.entries()) { - const { exists } = expectedCounters[i]; - - if (exists) { - expect( - root.get(counterId), - `Check counter #${i + 1} exists on root as OBJECT_DELETE op was not applied`, - ).to.exist; - } else { - expect( - root.get(counterId), - `Check counter #${i + 1} does not exist on root as OBJECT_DELETE op was applied`, - ).to.not.exist; - } - } - }, - }, - - { - description: 'OBJECT_DELETE triggers subscription callback with deleted data', - action: async (ctx) => { - const { root, objectsHelper, channelName, channel } = ctx; - - const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'map'), - waitForMapKeyUpdate(root, 'counter'), - ]); - // create initial objects and set on root - const { objectId: mapObjectId } = await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'map', - createOp: objectsHelper.mapCreateRestOp({ - data: { - foo: { string: 'bar' }, - baz: { number: 1 }, - }, - }), - }); - const { objectId: counterObjectId } = await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'counter', - createOp: objectsHelper.counterCreateRestOp({ number: 1 }), - }); - await objectsCreatedPromise; - - const mapSubPromise = new Promise((resolve, reject) => - root.get('map').subscribe((update) => { - 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', - ); - resolve(); - } catch (error) { - reject(error); - } - }), - ); - const counterSubPromise = new Promise((resolve, reject) => - root.get('counter').subscribe((update) => { - try { - expect(update?.update).to.deep.equal( - { amount: -1 }, - 'Check counter subscription callback is called with an expected update object after OBJECT_DELETE operation', - ); - resolve(); - } catch (error) { - reject(error); - } - }), - ); - - // inject OBJECT_DELETE - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 0, 0), - siteCode: 'aaa', - state: [objectsHelper.objectDeleteOp({ objectId: mapObjectId })], - }); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 1, 0), - siteCode: 'aaa', - state: [objectsHelper.objectDeleteOp({ objectId: counterObjectId })], - }); - - await Promise.all([mapSubPromise, counterSubPromise]); - }, - }, - - { - description: 'OBJECT_DELETE for an object sets "tombstoneAt" from "serialTimestamp"', - action: async (ctx) => { - const { root, objectsHelper, channelName, channel, helper, objects } = ctx; - - const objectCreatedPromise = waitForMapKeyUpdate(root, 'object'); - const { objectId } = await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'object', - createOp: objectsHelper.counterCreateRestOp(), - }); - await objectCreatedPromise; - - expect(root.get('object'), 'Check object exists on root before OBJECT_DELETE').to.exist; - - // inject OBJECT_DELETE - const serialTimestamp = 1234567890; - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 1, 0), - serialTimestamp, - siteCode: 'aaa', - state: [objectsHelper.objectDeleteOp({ objectId })], - }); - - helper.recordPrivateApi('call.Objects._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`); - helper.recordPrivateApi('call.LiveObject.tombstonedAt'); - expect(obj.tombstonedAt()).to.equal( - serialTimestamp, - `Check object's "tombstonedAt" value is set to "serialTimestamp" from OBJECT_DELETE`, - ); - }, - }, - - { - 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 objectCreatedPromise = waitForMapKeyUpdate(root, 'object'); - const { objectId } = await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'object', - createOp: objectsHelper.counterCreateRestOp(), - }); - await objectCreatedPromise; - - expect(root.get('object'), 'Check object exists on root before OBJECT_DELETE').to.exist; - - const tsBeforeMsg = Date.now(); - // inject OBJECT_DELETE - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 1, 0), - // don't provide serialTimestamp - siteCode: 'aaa', - state: [objectsHelper.objectDeleteOp({ objectId })], - }); - const tsAfterMsg = Date.now(); - - helper.recordPrivateApi('call.Objects._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`); - helper.recordPrivateApi('call.LiveObject.tombstonedAt'); - expect( - tsBeforeMsg <= obj.tombstonedAt() <= tsAfterMsg, - `Check object's "tombstonedAt" value is set using local clock if no "serialTimestamp" provided`, - ).to.be.true; - }, - }, - - { - 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 objectCreatedPromise = waitForMapKeyUpdate(root, 'foo'); - // create initial objects and set on root - const { objectId: counterObjectId } = await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'foo', - createOp: objectsHelper.counterCreateRestOp(), - }); - await objectCreatedPromise; - - expect(root.get('foo'), 'Check counter exists on root before OBJECT_DELETE').to.exist; - - // inject OBJECT_DELETE - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 0, 0), - siteCode: 'aaa', - state: [objectsHelper.objectDeleteOp({ objectId: counterObjectId })], - }); - - // set tombstoned counter to another key on root - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 0, 0), - siteCode: 'aaa', - 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; - }, - }, - - { - description: 'object operation message on a tombstoned object does not revive it', - action: async (ctx) => { - const { root, objectsHelper, channelName, channel } = ctx; - - const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'map1'), - waitForMapKeyUpdate(root, 'map2'), - waitForMapKeyUpdate(root, 'counter1'), - ]); - // create initial objects and set on root - const { objectId: mapId1 } = await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'map1', - createOp: objectsHelper.mapCreateRestOp(), - }); - const { objectId: mapId2 } = await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'map2', - createOp: objectsHelper.mapCreateRestOp({ data: { foo: { string: 'bar' } } }), - }); - const { objectId: counterId1 } = await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'counter1', - createOp: objectsHelper.counterCreateRestOp(), - }); - 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; - - // inject OBJECT_DELETE - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 0, 0), - siteCode: 'aaa', - state: [objectsHelper.objectDeleteOp({ objectId: mapId1 })], - }); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 1, 0), - siteCode: 'aaa', - state: [objectsHelper.objectDeleteOp({ objectId: mapId2 })], - }); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 2, 0), - siteCode: 'aaa', - state: [objectsHelper.objectDeleteOp({ objectId: counterId1 })], - }); - - // inject object operations on tombstoned objects - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 3, 0), - siteCode: 'aaa', - state: [objectsHelper.mapSetOp({ objectId: mapId1, key: 'baz', data: { string: 'qux' } })], - }); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 4, 0), - siteCode: 'aaa', - state: [objectsHelper.mapRemoveOp({ objectId: mapId2, key: 'foo' })], - }); - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 5, 0), - siteCode: 'aaa', - state: [objectsHelper.counterIncOp({ objectId: counterId1, amount: 1 })], - }); - - // 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'), - 'Check counter1 does not exist on root after OBJECT_DELETE and another object op', - ).to.not.exist; - }, - }, - ]; - - const applyOperationsDuringSyncScenarios = [ - { - description: 'object operation messages are buffered during OBJECT_SYNC sequence', - action: async (ctx) => { - const { root, 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({ - channel, - syncSerial: 'serial:cursor', - }); - - // inject operations, it should not be applied as sync is in progress - await Promise.all( - primitiveKeyData.map(async (keyData) => { - // copy data object as library will modify it - const data = { ...keyData.data }; - helper.recordPrivateApi('read.realtime.options.useBinaryProtocol'); - if (data.bytes != null && client.options.useBinaryProtocol) { - // decode base64 data to binary for binary protocol - helper.recordPrivateApi('call.BufferUtils.base64Decode'); - data.bytes = BufferUtils.base64Decode(data.bytes); - } - - return objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 0, 0), - siteCode: 'aaa', - state: [objectsHelper.mapSetOp({ objectId: 'root', key: keyData.key, data })], - }); - }), - ); - - // 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; - }); - }, - }, - - { - description: 'buffered object operation messages are applied when OBJECT_SYNC sequence ends', - action: async (ctx) => { - const { root, 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({ - channel, - syncSerial: 'serial:cursor', - }); - - // inject operations, they should be applied when sync ends - await Promise.all( - primitiveKeyData.map(async (keyData, i) => { - // copy data object as library will modify it - const data = { ...keyData.data }; - helper.recordPrivateApi('read.realtime.options.useBinaryProtocol'); - if (data.bytes != null && client.options.useBinaryProtocol) { - // decode base64 data to binary for binary protocol - helper.recordPrivateApi('call.BufferUtils.base64Decode'); - data.bytes = BufferUtils.base64Decode(data.bytes); - } - - return objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', i, 0), - siteCode: 'aaa', - state: [objectsHelper.mapSetOp({ objectId: 'root', key: keyData.key, data })], - }); - }), - ); - - // end the sync with empty cursor - await objectsHelper.processObjectStateMessageOnChannel({ - channel, - syncSerial: 'serial:', - }); - - // check everything is applied correctly - primitiveKeyData.forEach((keyData) => { - checkKeyDataOnMap({ - helper, - key: keyData.key, - keyData, - mapObj: root, - msg: `Check root has correct value for "${keyData.key}" key after OBJECT_SYNC has ended and buffered operations are applied`, - }); - }); - }, - }, - - { - description: 'buffered object operation messages are discarded when new OBJECT_SYNC sequence starts', - action: async (ctx) => { - const { root, 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({ - channel, - syncSerial: 'serial:cursor', - }); - - // inject operations, expect them to be discarded when sync with new sequence id starts - await Promise.all( - primitiveKeyData.map(async (keyData, i) => { - // copy data object as library will modify it - const data = { ...keyData.data }; - helper.recordPrivateApi('read.realtime.options.useBinaryProtocol'); - if (data.bytes != null && client.options.useBinaryProtocol) { - // decode base64 data to binary for binary protocol - helper.recordPrivateApi('call.BufferUtils.base64Decode'); - data.bytes = BufferUtils.base64Decode(data.bytes); - } - - return objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', i, 0), - siteCode: 'aaa', - state: [objectsHelper.mapSetOp({ objectId: 'root', key: keyData.key, data })], - }); - }), - ); - - // start new sync with new sequence id - await objectsHelper.processObjectStateMessageOnChannel({ - channel, - syncSerial: 'otherserial:cursor', - }); - - // inject another operation that should be applied when latest sync ends - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('bbb', 0, 0), - siteCode: 'bbb', - state: [objectsHelper.mapSetOp({ objectId: 'root', key: 'foo', data: { string: 'bar' } })], - }); - - // end sync - await objectsHelper.processObjectStateMessageOnChannel({ - channel, - syncSerial: 'otherserial:', - }); - - // check root doesn't have data from operations received during first sync - primitiveKeyData.forEach((keyData) => { - expect( - root.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( - 'bar', - 'Check root has data from operations received during second OBJECT_SYNC sequence', - ); - }, - }, - - { - description: - 'buffered object operation messages are applied based on the site timeserials vector of the object', - action: async (ctx) => { - const { root, objectsHelper, channel } = ctx; - - // start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages - const mapId = objectsHelper.fakeMapObjectId(); - const counterId = objectsHelper.fakeCounterObjectId(); - await objectsHelper.processObjectStateMessageOnChannel({ - channel, - syncSerial: 'serial:cursor', - // add object state messages with non-empty site timeserials - state: [ - // next map and counter objects will be checked to have correct operations applied on them based on site timeserials - objectsHelper.mapObject({ - objectId: mapId, - siteTimeserials: { - bbb: lexicoTimeserial('bbb', 2, 0), - ccc: lexicoTimeserial('ccc', 5, 0), - }, - materialisedEntries: { - foo1: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, - foo2: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, - foo3: { timeserial: lexicoTimeserial('ccc', 5, 0), data: { string: 'bar' } }, - foo4: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'bar' } }, - foo5: { timeserial: lexicoTimeserial('bbb', 2, 0), data: { string: 'bar' } }, - foo6: { timeserial: lexicoTimeserial('ccc', 2, 0), data: { string: 'bar' } }, - foo7: { timeserial: lexicoTimeserial('ccc', 0, 0), data: { string: 'bar' } }, - foo8: { timeserial: lexicoTimeserial('ccc', 0, 0), data: { string: 'bar' } }, - }, - }), - objectsHelper.counterObject({ - objectId: counterId, - siteTimeserials: { - bbb: lexicoTimeserial('bbb', 1, 0), - }, - initialCount: 1, - }), - // add objects to the root so they're discoverable in the object tree - objectsHelper.mapObject({ - objectId: 'root', - siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) }, - initialEntries: { - map: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: mapId } }, - counter: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: counterId } }, - }, - }), - ], - }); - - // inject operations with various timeserial values - // Map: - for (const [i, { serial, siteCode }] of [ - { serial: lexicoTimeserial('bbb', 1, 0), siteCode: 'bbb' }, // existing site, earlier site CGO, not applied - { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // existing site, same site CGO, not applied - { serial: lexicoTimeserial('bbb', 3, 0), siteCode: 'bbb' }, // existing site, later site CGO, earlier entry CGO, not applied but site timeserial updated - // message with later site CGO, same entry CGO case is not possible, as timeserial from entry would be set for the corresponding site code or be less than that - { serial: lexicoTimeserial('bbb', 3, 0), siteCode: 'bbb' }, // existing site, same site CGO (updated from last op), later entry CGO, not applied - { serial: lexicoTimeserial('bbb', 4, 0), siteCode: 'bbb' }, // existing site, later site CGO, later entry CGO, applied - { serial: lexicoTimeserial('aaa', 1, 0), siteCode: 'aaa' }, // different site, earlier entry CGO, not applied but site timeserial updated - { serial: lexicoTimeserial('aaa', 1, 0), siteCode: 'aaa' }, // different site, same site CGO (updated from last op), later entry CGO, not applied - // different site with matching entry CGO case is not possible, as matching entry timeserial means that that timeserial is in the site timeserials vector - { serial: lexicoTimeserial('ddd', 1, 0), siteCode: 'ddd' }, // different site, later entry CGO, applied - ].entries()) { - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial, - siteCode, - state: [objectsHelper.mapSetOp({ objectId: mapId, key: `foo${i + 1}`, data: { string: 'baz' } })], - }); - } - - // Counter: - for (const [i, { serial, siteCode }] of [ - { serial: lexicoTimeserial('bbb', 0, 0), siteCode: 'bbb' }, // +10 existing site, earlier CGO, not applied - { serial: lexicoTimeserial('bbb', 1, 0), siteCode: 'bbb' }, // +100 existing site, same CGO, not applied - { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // +1000 existing site, later CGO, applied, site timeserials updated - { serial: lexicoTimeserial('bbb', 2, 0), siteCode: 'bbb' }, // +10000 existing site, same CGO (updated from last op), not applied - { serial: lexicoTimeserial('aaa', 0, 0), siteCode: 'aaa' }, // +100000 different site, earlier CGO, applied - { serial: lexicoTimeserial('ccc', 9, 0), siteCode: 'ccc' }, // +1000000 different site, later CGO, applied - ].entries()) { - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial, - siteCode, - state: [objectsHelper.counterIncOp({ objectId: counterId, amount: Math.pow(10, i + 1) })], - }); - } - - // end sync - await objectsHelper.processObjectStateMessageOnChannel({ - channel, - syncSerial: 'serial:', - }); - - // check only operations with correct timeserials were applied - const expectedMapKeys = [ - { key: 'foo1', value: 'bar' }, - { key: 'foo2', value: 'bar' }, - { key: 'foo3', value: 'bar' }, - { key: 'foo4', value: 'bar' }, - { key: 'foo5', value: 'baz' }, // updated - { key: 'foo6', value: 'bar' }, - { key: 'foo7', value: 'bar' }, - { key: 'foo8', value: 'baz' }, // updated - ]; - - expectedMapKeys.forEach(({ key, value }) => { - expect(root.get('map').get(key)).to.equal( - value, - `Check "${key}" key on map has expected value after OBJECT_SYNC has ended`, - ); - }); - - expect(root.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`, - ); - }, - }, - - { - 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; - - // start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages - await objectsHelper.processObjectStateMessageOnChannel({ - channel, - syncSerial: 'serial:cursor', - }); - - // inject operations, they should be applied when sync ends - await Promise.all( - primitiveKeyData.map(async (keyData, i) => { - // copy data object as library will modify it - const data = { ...keyData.data }; - helper.recordPrivateApi('read.realtime.options.useBinaryProtocol'); - if (data.bytes != null && client.options.useBinaryProtocol) { - // decode base64 data to binary for binary protocol - helper.recordPrivateApi('call.BufferUtils.base64Decode'); - data.bytes = BufferUtils.base64Decode(data.bytes); - } - - return objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', i, 0), - siteCode: 'aaa', - state: [objectsHelper.mapSetOp({ objectId: 'root', key: keyData.key, data })], - }); - }), - ); - - // end the sync with empty cursor - await objectsHelper.processObjectStateMessageOnChannel({ - channel, - syncSerial: 'serial:', - }); - - const keyUpdatedPromise = waitForMapKeyUpdate(root, 'foo'); - // send some more operations - await objectsHelper.operationRequest( - channelName, - objectsHelper.mapSetRestOp({ - objectId: 'root', - key: 'foo', - value: { string: 'bar' }, - }), - ); - await keyUpdatedPromise; - - // check buffered operations are applied, as well as the most recent operation outside of the sync sequence is applied - primitiveKeyData.forEach((keyData) => { - checkKeyDataOnMap({ - helper, - key: keyData.key, - keyData, - mapObj: root, - 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( - 'bar', - 'Check root has correct value for "foo" key from operation received outside of OBJECT_SYNC after other buffered operations were applied', - ); - }, - }, - ]; - - const writeApiScenarios = [ - { - allTransportsAndProtocols: true, - description: 'LiveCounter.increment sends COUNTER_INC operation', - action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; - - const counterCreatedPromise = waitForMapKeyUpdate(root, 'counter'); - await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'counter', - createOp: objectsHelper.counterCreateRestOp(), - }); - await counterCreatedPromise; - - const counter = root.get('counter'); - const increments = [ - 1, // value=1 - 10, // value=11 - -11, // value=0 - -1, // value=-1 - -10, // value=-11 - 11, // value=0 - Number.MAX_SAFE_INTEGER, // value=9007199254740991 - -Number.MAX_SAFE_INTEGER, // value=0 - -Number.MAX_SAFE_INTEGER, // value=-9007199254740991 - ]; - let expectedCounterValue = 0; - - for (let i = 0; i < increments.length; i++) { - const increment = increments[i]; - expectedCounterValue += increment; - - const counterUpdatedPromise = waitForCounterUpdate(counter); - await counter.increment(increment); - await counterUpdatedPromise; - - expect(counter.value()).to.equal( - expectedCounterValue, - `Check counter has correct value after ${i + 1} LiveCounter.increment calls`, - ); - } - }, - }, - - { - description: 'LiveCounter.increment throws on invalid input', - action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; - - const counterCreatedPromise = waitForMapKeyUpdate(root, 'counter'); - await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'counter', - createOp: objectsHelper.counterCreateRestOp(), - }); - await counterCreatedPromise; - - const counter = root.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', - ); - await expectToThrowAsync( - async () => counter.increment(Number.POSITIVE_INFINITY), - 'Counter value increment should be a valid number', - ); - await expectToThrowAsync( - async () => counter.increment(Number.NEGATIVE_INFINITY), - 'Counter value increment should be a valid number', - ); - await expectToThrowAsync( - async () => counter.increment('foo'), - 'Counter value increment should be a valid number', - ); - await expectToThrowAsync( - async () => counter.increment(BigInt(1)), - 'Counter value increment should be a valid number', - ); - await expectToThrowAsync( - async () => counter.increment(true), - 'Counter value increment should be a valid number', - ); - await expectToThrowAsync( - async () => counter.increment(Symbol()), - 'Counter value increment should be a valid number', - ); - await expectToThrowAsync( - async () => counter.increment({}), - 'Counter value increment should be a valid number', - ); - await expectToThrowAsync( - async () => counter.increment([]), - 'Counter value increment should be a valid number', - ); - await expectToThrowAsync( - async () => counter.increment(counter), - 'Counter value increment should be a valid number', - ); - }, - }, - - { - allTransportsAndProtocols: true, - description: 'LiveCounter.decrement sends COUNTER_INC operation', - action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; - - const counterCreatedPromise = waitForMapKeyUpdate(root, 'counter'); - await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'counter', - createOp: objectsHelper.counterCreateRestOp(), - }); - await counterCreatedPromise; - - const counter = root.get('counter'); - const decrements = [ - 1, // value=-1 - 10, // value=-11 - -11, // value=0 - -1, // value=1 - -10, // value=11 - 11, // value=0 - Number.MAX_SAFE_INTEGER, // value=-9007199254740991 - -Number.MAX_SAFE_INTEGER, // value=0 - -Number.MAX_SAFE_INTEGER, // value=9007199254740991 - ]; - let expectedCounterValue = 0; - - for (let i = 0; i < decrements.length; i++) { - const decrement = decrements[i]; - expectedCounterValue -= decrement; - - const counterUpdatedPromise = waitForCounterUpdate(counter); - await counter.decrement(decrement); - await counterUpdatedPromise; - - expect(counter.value()).to.equal( - expectedCounterValue, - `Check counter has correct value after ${i + 1} LiveCounter.decrement calls`, - ); - } - }, - }, - - { - description: 'LiveCounter.decrement throws on invalid input', - action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; - - const counterCreatedPromise = waitForMapKeyUpdate(root, 'counter'); - await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'counter', - createOp: objectsHelper.counterCreateRestOp(), - }); - await counterCreatedPromise; - - const counter = root.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', - ); - await expectToThrowAsync( - async () => counter.decrement(Number.POSITIVE_INFINITY), - 'Counter value decrement should be a valid number', - ); - await expectToThrowAsync( - async () => counter.decrement(Number.NEGATIVE_INFINITY), - 'Counter value decrement should be a valid number', - ); - await expectToThrowAsync( - async () => counter.decrement('foo'), - 'Counter value decrement should be a valid number', - ); - await expectToThrowAsync( - async () => counter.decrement(BigInt(1)), - 'Counter value decrement should be a valid number', - ); - await expectToThrowAsync( - async () => counter.decrement(true), - 'Counter value decrement should be a valid number', - ); - await expectToThrowAsync( - async () => counter.decrement(Symbol()), - 'Counter value decrement should be a valid number', - ); - await expectToThrowAsync( - async () => counter.decrement({}), - 'Counter value decrement should be a valid number', - ); - await expectToThrowAsync( - async () => counter.decrement([]), - 'Counter value decrement should be a valid number', - ); - await expectToThrowAsync( - async () => counter.decrement(counter), - 'Counter value decrement should be a valid number', - ); - }, - }, - - { - allTransportsAndProtocols: true, - description: 'LiveMap.set sends MAP_SET operation with primitive values', - action: async (ctx) => { - const { root, 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 everything is applied correctly - primitiveKeyData.forEach((keyData) => { - checkKeyDataOnMap({ - helper, - key: keyData.key, - keyData, - mapObj: root, - msg: `Check root has correct value for "${keyData.key}" key after LiveMap.set call`, - }); - }); - }, - }, - - { - allTransportsAndProtocols: true, - description: 'LiveMap.set sends MAP_SET operation with reference to another LiveObject', - action: async (ctx) => { - const { root, objectsHelper, channelName } = 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 keysUpdatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter2'), - waitForMapKeyUpdate(root, 'map2'), - ]); - await root.set('counter2', counter); - await root.set('map2', map); - 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', - ); - }, - }, - - { - description: 'LiveMap.set throws on invalid input', - action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; - - const mapCreatedPromise = waitForMapKeyUpdate(root, 'map'); - await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'map', - createOp: objectsHelper.mapCreateRestOp(), - }); - await mapCreatedPromise; - - const map = root.get('map'); - - await expectToThrowAsync(async () => map.set(), 'Map key should be string'); - await expectToThrowAsync(async () => map.set(null), 'Map key should be string'); - await expectToThrowAsync(async () => map.set(1), 'Map key should be string'); - await expectToThrowAsync(async () => map.set(BigInt(1)), 'Map key should be string'); - await expectToThrowAsync(async () => map.set(true), 'Map key should be string'); - await expectToThrowAsync(async () => map.set(Symbol()), 'Map key should be string'); - await expectToThrowAsync(async () => map.set({}), 'Map key should be string'); - await expectToThrowAsync(async () => map.set([]), 'Map key should be string'); - await expectToThrowAsync(async () => map.set(map), 'Map key should be string'); - - await expectToThrowAsync(async () => map.set('key'), 'Map value data type is unsupported'); - await expectToThrowAsync(async () => map.set('key', null), 'Map value data type is unsupported'); - await expectToThrowAsync(async () => map.set('key', BigInt(1)), 'Map value data type is unsupported'); - await expectToThrowAsync(async () => map.set('key', Symbol()), 'Map value data type is unsupported'); - }, - }, - - { - allTransportsAndProtocols: true, - description: 'LiveMap.remove sends MAP_REMOVE operation', - action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; - - const mapCreatedPromise = waitForMapKeyUpdate(root, 'map'); - await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'map', - createOp: objectsHelper.mapCreateRestOp({ - data: { - foo: { number: 1 }, - bar: { number: 1 }, - baz: { number: 1 }, - }, - }), - }); - await mapCreatedPromise; - - const map = root.get('map'); - - const keysUpdatedPromise = Promise.all([waitForMapKeyUpdate(map, 'foo'), waitForMapKeyUpdate(map, 'bar')]); - await map.remove('foo'); - await map.remove('bar'); - await keysUpdatedPromise; - - 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'), - 'Check non-removed keys are still present on a root after LiveMap.remove call for another keys', - ).to.equal(1); - }, - }, - - { - description: 'LiveMap.remove throws on invalid input', - action: async (ctx) => { - const { root, objectsHelper, channelName } = ctx; - - const mapCreatedPromise = waitForMapKeyUpdate(root, 'map'); - await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: 'map', - createOp: objectsHelper.mapCreateRestOp(), - }); - await mapCreatedPromise; - - const map = root.get('map'); - - await expectToThrowAsync(async () => map.remove(), 'Map key should be string'); - await expectToThrowAsync(async () => map.remove(null), 'Map key should be string'); - await expectToThrowAsync(async () => map.remove(1), 'Map key should be string'); - await expectToThrowAsync(async () => map.remove(BigInt(1)), 'Map key should be string'); - await expectToThrowAsync(async () => map.remove(true), 'Map key should be string'); - await expectToThrowAsync(async () => map.remove(Symbol()), 'Map key should be string'); - await expectToThrowAsync(async () => map.remove({}), 'Map key should be string'); - await expectToThrowAsync(async () => map.remove([]), 'Map key should be string'); - await expectToThrowAsync(async () => map.remove(map), 'Map key should be string'); - }, - }, - - { - allTransportsAndProtocols: true, - description: 'Objects.createCounter sends COUNTER_CREATE operation', - action: async (ctx) => { - const { objects } = ctx; - - const counters = await Promise.all(countersFixtures.map(async (x) => objects.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`, - ); - } - }, - }, - - { - allTransportsAndProtocols: true, - description: 'LiveCounter created with Objects.createCounter can be assigned to the object tree', - action: async (ctx) => { - const { root, objects } = ctx; - - const counterCreatedPromise = waitForMapKeyUpdate(root, 'counter'); - const counter = await objects.createCounter(1); - await root.set('counter', counter); - await counterCreatedPromise; - - 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', - ); - }, - }, - - { - description: - 'Objects.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'); - objects.publish = () => {}; - - const counter = await objects.createCounter(1); - expect(counter.value()).to.equal(1, `Check counter has expected initial value`); - }, - }, - - { - allTransportsAndProtocols: true, - description: 'Objects.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'); - 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 - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 1, 1), - siteCode: 'aaa', - state: [objectsHelper.counterCreateOp({ objectId: counterId, count: 10 })], - }); - }; - - const counter = await objects.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 Objects.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'); - objects.publish = () => {}; - - // create counter locally, should have an initial value set - const counter = await objects.createCounter(1); - helper.recordPrivateApi('call.LiveObject.getObjectId'); - const counterId = counter.getObjectId(); - - // 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 })], - }); - - expect(counter.value()).to.equal( - 1, - `Check counter initial value is not double counted after being created and receiving CREATE operation`, - ); - }, - }, - - { - description: 'Objects.createCounter throws on invalid input', - action: async (ctx) => { - const { root, objects } = ctx; - - await expectToThrowAsync(async () => objects.createCounter(null), 'Counter value should be a valid number'); - await expectToThrowAsync( - async () => objects.createCounter(Number.NaN), - 'Counter value should be a valid number', - ); - await expectToThrowAsync( - async () => objects.createCounter(Number.POSITIVE_INFINITY), - 'Counter value should be a valid number', - ); - await expectToThrowAsync( - async () => objects.createCounter(Number.NEGATIVE_INFINITY), - 'Counter value should be a valid number', - ); - await expectToThrowAsync( - async () => objects.createCounter('foo'), - 'Counter value should be a valid number', - ); - await expectToThrowAsync( - async () => objects.createCounter(BigInt(1)), - '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()), - '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: 'Objects.createMap sends MAP_CREATE operation with primitive values', - action: async (ctx) => { - const { objects, helper } = ctx; - - const maps = await Promise.all( - primitiveMapsFixtures.map(async (mapFixture) => { - const entries = mapFixture.entries - ? Object.entries(mapFixture.entries).reduce((acc, [key, 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; - } - - acc[key] = value; - return acc; - }, {}) - : undefined; - - return objects.createMap(entries); - }), - ); - - for (let i = 0; i < maps.length; i++) { - const map = maps[i]; - 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`); - - expect(map.size()).to.equal( - Object.keys(fixture.entries ?? {}).length, - `Check map #${i + 1} has correct number of keys`, - ); - - Object.entries(fixture.entries ?? {}).forEach(([key, keyData]) => { - checkKeyDataOnMap({ - helper, - key, - keyData, - mapObj: map, - msg: `Check map #${i + 1} has correct value for "${key}" key`, - }); - }); - } - }, - }, - - { - allTransportsAndProtocols: true, - description: 'Objects.createMap sends MAP_CREATE operation with reference to another LiveObject', - action: async (ctx) => { - const { root, objectsHelper, channelName, objects } = 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 objects.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 Objects.createMap can be assigned to the object tree', - action: async (ctx) => { - const { root, objects } = ctx; - - const mapCreatedPromise = waitForMapKeyUpdate(root, 'map'); - const counter = await objects.createCounter(); - const map = await objects.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', - ); - }, - }, - - { - description: 'Objects.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'); - objects.publish = () => {}; - - const map = await objects.createMap({ foo: 'bar' }); - expect(map.get('foo')).to.equal('bar', `Check map has expected initial value`); - }, - }, - - { - allTransportsAndProtocols: true, - description: 'Objects.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'); - 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 - 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' } } }, - }), - ], - }); - }; - - const map = await objects.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; - expect(map.get('baz')).to.equal( - 'qux', - `Check key "baz" was set on a map from a CREATE operation after object creation`, - ); - }, - }, - - { - description: - 'initial value is not double counted for LiveMap from Objects.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'); - objects.publish = () => {}; - - // create map locally, should have an initial value set - const map = await objects.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: 'Objects.createMap throws on invalid input', - action: async (ctx) => { - const { root, objects } = 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)), - '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()), - 'Map entries should be a key-value object', - ); - - await expectToThrowAsync( - async () => objects.createMap({ key: undefined }), - 'Map value data type is unsupported', - ); - await expectToThrowAsync( - async () => objects.createMap({ key: null }), - 'Map value data type is unsupported', - ); - await expectToThrowAsync( - async () => objects.createMap({ key: BigInt(1) }), - 'Map value data type is unsupported', - ); - await expectToThrowAsync( - async () => objects.createMap({ key: Symbol() }), - 'Map value data type is unsupported', - ); - }, - }, - - { - description: 'batch API getRoot method is synchronous', - action: async (ctx) => { - const { objects } = ctx; - - await objects.batch((ctx) => { - const root = ctx.getRoot(); - expect(root, 'Check getRoot method in a BatchContext returns root 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, objects } = ctx; - - const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), - ]); - const counter = await objects.createCounter(1); - const map = await objects.createMap({ innerCounter: counter }); - await root.set('counter', counter); - await root.set('map', map); - await objectsCreatedPromise; - - await objects.batch((ctx) => { - const ctxRoot = ctx.getRoot(); - const ctxCounter = ctxRoot.get('counter'); - const ctxMap = ctxRoot.get('map'); - const ctxInnerCounter = ctxMap.get('innerCounter'); - - expect(ctxCounter, 'Check counter object can be accessed from a map in a batch API').to.exist; - expectInstanceOf( - ctxCounter, - 'BatchContextLiveCounter', - 'Check counter object obtained in a batch API has a BatchContext specific wrapper type', - ); - expect(ctxMap, 'Check map object can be accessed from a map in a batch API').to.exist; - expectInstanceOf( - ctxMap, - 'BatchContextLiveMap', - 'Check map object obtained in a batch API has a BatchContext specific wrapper type', - ); - expect(ctxInnerCounter, 'Check inner counter object can be accessed from a map in a batch API').to.exist; - expectInstanceOf( - ctxInnerCounter, - 'BatchContextLiveCounter', - 'Check inner counter object obtained in a batch API has a BatchContext specific wrapper type', - ); - }); - }, - }, - - { - description: 'batch API access API methods on objects work and are synchronous', - action: async (ctx) => { - const { root, objects } = ctx; - - const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), - ]); - const counter = await objects.createCounter(1); - const map = await objects.createMap({ foo: 'bar' }); - await root.set('counter', counter); - await root.set('map', map); - await objectsCreatedPromise; - - await objects.batch((ctx) => { - const ctxRoot = ctx.getRoot(); - const ctxCounter = ctxRoot.get('counter'); - const ctxMap = ctxRoot.get('map'); - - expect(ctxCounter.value()).to.equal( - 1, - 'Check batch API counter .value() method works 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( - [['foo', 'bar']], - 'Check batch API map .entries() method works and is synchronous', - ); - expect([...ctxMap.keys()]).to.deep.equal( - ['foo'], - 'Check batch API map .keys() method works and is synchronous', - ); - expect([...ctxMap.values()]).to.deep.equal( - ['bar'], - 'Check batch API map .values() method works and is synchronous', - ); - }); - }, - }, - - { - description: 'batch API write API methods on objects do not mutate objects inside the batch callback', - action: async (ctx) => { - const { root, objects } = ctx; - - const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), - ]); - const counter = await objects.createCounter(1); - const map = await objects.createMap({ foo: 'bar' }); - await root.set('counter', counter); - await root.set('map', map); - await objectsCreatedPromise; - - await objects.batch((ctx) => { - const ctxRoot = ctx.getRoot(); - const ctxCounter = ctxRoot.get('counter'); - const ctxMap = ctxRoot.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', - ); - - ctxCounter.decrement(100); - expect(ctxCounter.value()).to.equal( - 1, - 'Check batch API counter .decrement method does not mutate the object inside the batch callback', - ); - - ctxMap.set('baz', 'qux'); - expect( - ctxMap.get('baz'), - 'Check batch API map .set method does not mutate the object inside the batch callback', - ).to.not.exist; - - ctxMap.remove('foo'); - expect(ctxMap.get('foo')).to.equal( - 'bar', - 'Check batch API map .remove method does not mutate the object inside the batch callback', - ); - }); - }, - }, - - { - allTransportsAndProtocols: true, - description: 'batch API scheduled operations are applied when batch callback is finished', - action: async (ctx) => { - const { root, objects } = ctx; - - const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), - ]); - const counter = await objects.createCounter(1); - const map = await objects.createMap({ foo: 'bar' }); - await root.set('counter', counter); - await root.set('map', map); - await objectsCreatedPromise; - - await objects.batch((ctx) => { - const ctxRoot = ctx.getRoot(); - const ctxCounter = ctxRoot.get('counter'); - const ctxMap = ctxRoot.get('map'); - - ctxCounter.increment(10); - ctxCounter.decrement(100); - - ctxMap.set('baz', 'qux'); - ctxMap.remove('foo'); - }); - - 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('foo'), 'Check key "foo" is removed from map after batch call').to.not.exist; - }, - }, - - { - description: 'batch API can be called without scheduling any operations', - action: async (ctx) => { - const { objects } = ctx; - - let caughtError; - try { - await objects.batch((ctx) => {}); - } catch (error) { - caughtError = error; - } - expect( - caughtError, - `Check batch API can be called without scheduling any 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', - action: async (ctx) => { - const { root, objects } = ctx; - - const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), - ]); - const counter = await objects.createCounter(1); - const map = await objects.createMap({ foo: 'bar' }); - await root.set('counter', counter); - await root.set('map', map); - await objectsCreatedPromise; - - const cancelError = new Error('cancel batch'); - let caughtError; - try { - await objects.batch((ctx) => { - const ctxRoot = ctx.getRoot(); - const ctxCounter = ctxRoot.get('counter'); - const ctxMap = ctxRoot.get('map'); - - ctxCounter.increment(10); - ctxCounter.decrement(100); - - ctxMap.set('baz', 'qux'); - ctxMap.remove('foo'); - - throw cancelError; - }); - } catch (error) { - caughtError = error; - } - - 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(caughtError).to.equal( - cancelError, - 'Check error from a batch callback was rethrown by a batch method', - ); - }, - }, - - { - 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 objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), - ]); - const counter = await objects.createCounter(1); - const map = await objects.createMap({ foo: 'bar' }); - await root.set('counter', counter); - await root.set('map', map); - await objectsCreatedPromise; - - let savedCtx; - let savedCtxCounter; - let savedCtxMap; - - await objects.batch((ctx) => { - const ctxRoot = ctx.getRoot(); - savedCtx = ctx; - savedCtxCounter = ctxRoot.get('counter'); - savedCtxMap = ctxRoot.get('map'); - }); - - expectAccessBatchApiToThrow({ - ctx: savedCtx, - map: savedCtxMap, - counter: savedCtxCounter, - errorMsg: 'Batch is closed', - }); - expectWriteBatchApiToThrow({ - 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`, - action: async (ctx) => { - const { root, objects } = ctx; - - const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'counter'), - waitForMapKeyUpdate(root, 'map'), - ]); - const counter = await objects.createCounter(1); - const map = await objects.createMap({ foo: 'bar' }); - await root.set('counter', counter); - await root.set('map', map); - await objectsCreatedPromise; - - let savedCtx; - let savedCtxCounter; - let savedCtxMap; - - let caughtError; - try { - await objects.batch((ctx) => { - const ctxRoot = ctx.getRoot(); - savedCtx = ctx; - savedCtxCounter = ctxRoot.get('counter'); - savedCtxMap = ctxRoot.get('map'); - - throw new Error('cancel batch'); - }); - } catch (error) { - caughtError = error; - } - - expect(caughtError, 'Check batch call failed with an error').to.exist; - expectAccessBatchApiToThrow({ - ctx: savedCtx, - map: savedCtxMap, - counter: savedCtxCounter, - errorMsg: 'Batch is closed', - }); - expectWriteBatchApiToThrow({ - ctx: savedCtx, - map: savedCtxMap, - counter: savedCtxCounter, - errorMsg: 'Batch is closed', - }); - }, - }, - ]; - - 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', - ); - }, - }, - { - description: `BatchContextLiveMap enumeration`, - action: async (ctx) => { - const { root, objectsHelper, channel, objects } = 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 objects.batch(async (ctx) => { - const ctxRoot = ctx.getRoot(); - - // 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', - ); - }); - }, - }, - ]; - - /** @nospec */ - forScenarios( - this, - [ - ...objectSyncSequenceScenarios, - ...applyOperationsScenarios, - ...applyOperationsDuringSyncScenarios, - ...writeApiScenarios, - ...liveMapEnumerationScenarios, - ], - async function (helper, scenario, clientOptions, channelName) { - 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; - - await channel.attach(); - const root = await objects.getRoot(); - - await scenario.action({ - objects, - root, - objectsHelper, - channelName, - channel, - client, - helper, - clientOptions, - }); - }, client); - }, - ); - - const subscriptionCallbacksScenarios = [ - { - 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 counter = root.get(sampleCounterKey); - const subscriptionPromise = new Promise((resolve, reject) => - counter.subscribe((update) => { - try { - expect(update?.update).to.deep.equal( - { amount: 1 }, - 'Check counter subscription callback is called with an expected update object for COUNTER_INC operation', - ); - resolve(); - } catch (error) { - reject(error); - } - }), - ); - - await objectsHelper.operationRequest( - channelName, - objectsHelper.counterIncRestOp({ - objectId: sampleCounterObjectId, - number: 1, - }), - ); - - await subscriptionPromise; - }, - }, - - { - allTransportsAndProtocols: true, - description: 'can subscribe to multiple incoming operations on a LiveCounter', - action: async (ctx) => { - const { root, objectsHelper, channelName, sampleCounterKey, sampleCounterObjectId } = ctx; - - const counter = root.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) => { - 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`, - ); - - if (currentUpdateIndex === expectedCounterIncrements.length - 1) { - resolve(); - } - - currentUpdateIndex++; - } catch (error) { - reject(error); - } - }), - ); - - for (const increment of expectedCounterIncrements) { - await objectsHelper.operationRequest( - channelName, - objectsHelper.counterIncRestOp({ - objectId: sampleCounterObjectId, - number: increment, - }), - ); - } - - await subscriptionPromise; - }, - }, - - { - 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 map = root.get(sampleMapKey); - const subscriptionPromise = new Promise((resolve, reject) => - map.subscribe((update) => { - try { - expect(update?.update).to.deep.equal( - { stringKey: 'updated' }, - 'Check map subscription callback is called with an expected update object for MAP_SET operation', - ); - resolve(); - } catch (error) { - reject(error); - } - }), - ); - - await objectsHelper.operationRequest( - channelName, - objectsHelper.mapSetRestOp({ - objectId: sampleMapObjectId, - key: 'stringKey', - value: { string: 'stringValue' }, - }), - ); - - await subscriptionPromise; - }, - }, - - { - 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 map = root.get(sampleMapKey); - const subscriptionPromise = new Promise((resolve, reject) => - map.subscribe((update) => { - try { - expect(update?.update).to.deep.equal( - { stringKey: 'removed' }, - 'Check map subscription callback is called with an expected update object for MAP_REMOVE operation', - ); - resolve(); - } catch (error) { - reject(error); - } - }), - ); - - await objectsHelper.operationRequest( - channelName, - objectsHelper.mapRemoveRestOp({ - objectId: sampleMapObjectId, - key: 'stringKey', - }), - ); - - await subscriptionPromise; - }, - }, - - { - 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.objects.getRoot(); - - // 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 map = root.get(sampleMapKey); - const expectedMapUpdates = [ - { foo: 'updated' }, - { bar: 'updated' }, - { foo: 'removed' }, - { baz: 'updated' }, - { bar: 'removed' }, - ]; - let currentUpdateIndex = 0; - - const subscriptionPromise = new Promise((resolve, reject) => - map.subscribe((update) => { - try { - expect(update?.update).to.deep.equal( - expectedMapUpdates[currentUpdateIndex], - `Check map subscription callback is called with an expected update object for ${currentUpdateIndex + 1} times`, - ); - - if (currentUpdateIndex === expectedMapUpdates.length - 1) { - resolve(); - } - - currentUpdateIndex++; - } catch (error) { - reject(error); - } - }), - ); - - await objectsHelper.operationRequest( - channelName, - objectsHelper.mapSetRestOp({ - objectId: sampleMapObjectId, - key: 'foo', - value: { string: 'something' }, - }), - ); - - await objectsHelper.operationRequest( - channelName, - objectsHelper.mapSetRestOp({ - objectId: sampleMapObjectId, - key: 'bar', - value: { string: 'something' }, - }), - ); - - await objectsHelper.operationRequest( - channelName, - objectsHelper.mapRemoveRestOp({ - objectId: sampleMapObjectId, - key: 'foo', - }), - ); - - await objectsHelper.operationRequest( - channelName, - objectsHelper.mapSetRestOp({ - objectId: sampleMapObjectId, - key: 'baz', - value: { string: 'something' }, - }), - ); - - await objectsHelper.operationRequest( - channelName, - objectsHelper.mapRemoveRestOp({ - objectId: sampleMapObjectId, - key: 'bar', - }), - ); - - await subscriptionPromise; - }, - }, - - { - description: 'can unsubscribe from LiveCounter updates via returned "unsubscribe" callback', - action: async (ctx) => { - const { root, objectsHelper, channelName, sampleCounterKey, sampleCounterObjectId } = ctx; - - const counter = root.get(sampleCounterKey); - let callbackCalled = 0; - const subscriptionPromise = new Promise((resolve) => { - const { unsubscribe } = counter.subscribe(() => { - callbackCalled++; - // unsubscribe from future updates after the first call - unsubscribe(); - resolve(); - }); - }); - - 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; - } - - await subscriptionPromise; - - expect(counter.value()).to.equal(3, 'Check counter has final expected value after all increments'); - expect(callbackCalled).to.equal(1, 'Check subscription callback was only called once'); - }, - }, - - { - description: 'can unsubscribe from LiveCounter updates via LiveCounter.unsubscribe() call', - action: async (ctx) => { - const { root, objectsHelper, channelName, sampleCounterKey, sampleCounterObjectId } = ctx; - - const counter = root.get(sampleCounterKey); - let callbackCalled = 0; - const subscriptionPromise = new Promise((resolve) => { - const listener = () => { - callbackCalled++; - // unsubscribe from future updates after the first call - counter.unsubscribe(listener); - resolve(); - }; - - counter.subscribe(listener); - }); - - 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; - } - - await subscriptionPromise; - - expect(counter.value()).to.equal(3, 'Check counter has final expected value after all increments'); - expect(callbackCalled).to.equal(1, 'Check subscription callback was only called once'); - }, - }, - - { - 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 map = root.get(sampleMapKey); - let callbackCalled = 0; - const subscriptionPromise = new Promise((resolve) => { - const { unsubscribe } = map.subscribe(() => { - callbackCalled++; - // unsubscribe from future updates after the first call - unsubscribe(); - resolve(); - }); - }); - - 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; - } - - await subscriptionPromise; - - 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`, - ); - } - expect(callbackCalled).to.equal(1, 'Check subscription callback was only called once'); - }, - }, - - { - description: 'can unsubscribe from LiveMap updates via LiveMap.unsubscribe() call', - action: async (ctx) => { - const { root, objectsHelper, channelName, sampleMapKey, sampleMapObjectId } = ctx; - - const map = root.get(sampleMapKey); - let callbackCalled = 0; - const subscriptionPromise = new Promise((resolve) => { - const listener = () => { - callbackCalled++; - // unsubscribe from future updates after the first call - map.unsubscribe(listener); - resolve(); - }; - - map.subscribe(listener); - }); - - 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; - } - - await subscriptionPromise; - - 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`, - ); - } - 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 */ - forScenarios(this, subscriptionCallbacksScenarios, async function (helper, scenario, clientOptions, channelName) { - 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; - - await channel.attach(); - const root = await objects.getRoot(); - - const sampleMapKey = 'sampleMap'; - const sampleCounterKey = 'sampleCounter'; - - const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, sampleMapKey), - waitForMapKeyUpdate(root, sampleCounterKey), - ]); - // prepare map and counter objects for use by the scenario - const { objectId: sampleMapObjectId } = await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: sampleMapKey, - createOp: objectsHelper.mapCreateRestOp(), - }); - const { objectId: sampleCounterObjectId } = await objectsHelper.createAndSetOnMap(channelName, { - mapObjectId: 'root', - key: sampleCounterKey, - createOp: objectsHelper.counterCreateRestOp(), - }); - await objectsCreatedPromise; - - await scenario.action({ - root, - objectsHelper, - channelName, - channel, - sampleMapKey, - sampleMapObjectId, - sampleCounterKey, - sampleCounterObjectId, - helper, - }); - }, client); - }); - - it('gcGracePeriod is set from connectionDetails.objectsGCGracePeriod', async function () { - const helper = this.test.helper; - const client = RealtimeWithObjects(helper); - - await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - await client.connection.once('connected'); - - const channel = client.channels.get('channel', channelOptionsWithObjects()); - const objects = channel.objects; - const connectionManager = client.connection.connectionManager; - const connectionDetails = connectionManager.connectionDetails; - - // gcGracePeriod should be set after the initial connection - helper.recordPrivateApi('read.Objects.gcGracePeriod'); - expect( - objects.gcGracePeriod, - 'Check gcGracePeriod is set after initial connection from connectionDetails.objectsGCGracePeriod', - ).to.exist; - helper.recordPrivateApi('read.Objects.gcGracePeriod'); - expect(objects.gcGracePeriod).to.equal( - connectionDetails.objectsGCGracePeriod, - 'Check gcGracePeriod is set to equal connectionDetails.objectsGCGracePeriod', - ); - - const connectionDetailsPromise = connectionManager.once('connectiondetails'); - - helper.recordPrivateApi('call.connectionManager.activeProtocol.getTransport'); - helper.recordPrivateApi('call.transport.onProtocolMessage'); - helper.recordPrivateApi('call.makeProtocolMessageFromDeserialized'); - connectionManager.activeProtocol.getTransport().onProtocolMessage( - createPM({ - action: 4, // CONNECTED - connectionDetails: { - ...connectionDetails, - objectsGCGracePeriod: 999, - }, - }), - ); - - helper.recordPrivateApi('listen.connectionManager.connectiondetails'); - await connectionDetailsPromise; - // 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'); - expect(objects.gcGracePeriod).to.equal(999, 'Check gcGracePeriod is updated on new CONNECTED event'); - }, client); - }); - - it('gcGracePeriod has a default value if connectionDetails.objectsGCGracePeriod is missing', async function () { - const helper = this.test.helper; - const client = RealtimeWithObjects(helper); - - await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - await client.connection.once('connected'); - - const channel = client.channels.get('channel', channelOptionsWithObjects()); - const objects = channel.objects; - const connectionManager = client.connection.connectionManager; - const connectionDetails = connectionManager.connectionDetails; - - helper.recordPrivateApi('read.Objects._DEFAULTS.gcGracePeriod'); - helper.recordPrivateApi('write.Objects.gcGracePeriod'); - // set gcGracePeriod to a value different from the default - objects.gcGracePeriod = ObjectsPlugin.Objects._DEFAULTS.gcGracePeriod + 1; - - const connectionDetailsPromise = connectionManager.once('connectiondetails'); - - // send a CONNECTED event without objectsGCGracePeriod, it should use the default value instead - helper.recordPrivateApi('call.connectionManager.activeProtocol.getTransport'); - helper.recordPrivateApi('call.transport.onProtocolMessage'); - helper.recordPrivateApi('call.makeProtocolMessageFromDeserialized'); - connectionManager.activeProtocol.getTransport().onProtocolMessage( - createPM({ - action: 4, // CONNECTED - connectionDetails, - }), - ); - - helper.recordPrivateApi('listen.connectionManager.connectiondetails'); - await connectionDetailsPromise; - // 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'); - expect(objects.gcGracePeriod).to.equal( - ObjectsPlugin.Objects._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. - // 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, objects, helper, waitForGCCycles, client } = ctx; - - const counterCreatedPromise = waitForObjectOperation(helper, client, ObjectsHelper.ACTIONS.COUNTER_CREATE); - // send a CREATE op, this adds an object to the pool - const { objectId } = await objectsHelper.operationRequest( - channelName, - objectsHelper.counterCreateRestOp({ number: 1 }), - ); - await counterCreatedPromise; - - helper.recordPrivateApi('call.Objects._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 - await objectsHelper.processObjectOperationMessageOnChannel({ - channel, - serial: lexicoTimeserial('aaa', 0, 0), - siteCode: 'aaa', - state: [objectsHelper.objectDeleteOp({ objectId })], - }); - - helper.recordPrivateApi('call.Objects._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.LiveObject.isTombstoned'); - expect(objects._objectsPool.get(objectId).isTombstoned()).to.equal( - true, - `Check object's "tombstone" flag is set to "true" after OBJECT_DELETE`, - ); - - // we expect 2 cycles to guarantee that grace period has expired, which will always be true based on the test config used - 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'); - expect( - objects._objectsPool.get(objectId), - 'Check object exists does not exist in the pool after the GC grace period expiration', - ).to.not.exist; - }, - }, - - { - 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 keyUpdatedPromise = waitForMapKeyUpdate(root, 'foo'); - // set a key on a root - await objectsHelper.operationRequest( - channelName, - objectsHelper.mapSetRestOp({ objectId: 'root', key: 'foo', value: { string: 'bar' } }), - ); - await keyUpdatedPromise; - - expect(root.get('foo')).to.equal('bar', 'Check key "foo" exists on root after MAP_SET'); - - const keyUpdatedPromise2 = waitForMapKeyUpdate(root, '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, - objectsHelper.mapRemoveRestOp({ objectId: 'root', key: 'foo' }), - ); - await keyUpdatedPromise2; - - expect(root.get('foo'), 'Check key "foo" is inaccessible via public API on root after MAP_REMOVE').to.not - .exist; - helper.recordPrivateApi('read.LiveMap._dataRef.data'); - expect( - root._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.LiveMap._dataRef.data'); - expect( - root._dataRef.data.get('foo').tombstone, - 'Check map entry for "foo" on root has "tombstone" flag set to "true" after MAP_REMOVE', - ).to.exist; - - // we expect 2 cycles to guarantee that grace period has expired, which will always be true based on the test config used - await waitForGCCycles(2); - - // the entry should be removed from the underlying map now - helper.recordPrivateApi('read.LiveMap._dataRef.data'); - expect( - root._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; - }, - }, - ]; - - /** @nospec */ - forScenarios(this, tombstonesGCScenarios, async function (helper, scenario, clientOptions, channelName) { - try { - helper.recordPrivateApi('write.Objects._DEFAULTS.gcInterval'); - ObjectsPlugin.Objects._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; - - await channel.attach(); - const root = await objects.getRoot(); - - helper.recordPrivateApi('read.Objects.gcGracePeriod'); - const gcGracePeriodOriginal = objects.gcGracePeriod; - helper.recordPrivateApi('write.Objects.gcGracePeriod'); - objects.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; - let gcCalledTimes = 0; - return new Promise((resolve) => { - helper.recordPrivateApi('replace.Objects._objectsPool._onGCInterval'); - objects._objectsPool._onGCInterval = function () { - helper.recordPrivateApi('call.Objects._objectsPool._onGCInterval'); - onGCIntervalOriginal.call(this); - - gcCalledTimes++; - if (gcCalledTimes >= cycles) { - resolve(); - objects._objectsPool._onGCInterval = onGCIntervalOriginal; - } - }; - }); - }; - - await scenario.action({ - client, - root, - objectsHelper, - channelName, - channel, - objects, - helper, - waitForGCCycles, - }); - - helper.recordPrivateApi('write.Objects.gcGracePeriod'); - objects.gcGracePeriod = gcGracePeriodOriginal; - }, client); - } finally { - helper.recordPrivateApi('write.Objects._DEFAULTS.gcInterval'); - ObjectsPlugin.Objects._DEFAULTS.gcInterval = gcIntervalOriginal; - } - }); - - const expectAccessApiToThrow = async ({ objects, map, counter, errorMsg }) => { - await expectToThrowAsync(async () => objects.getRoot(), 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); - - 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 - } - }; - - 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); - - 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]) { - expect(() => obj.unsubscribe(() => {})).not.to.throw(); // this should not throw - expect(() => obj.unsubscribeAll()).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 expectAccessBatchApiToThrow = ({ ctx, map, counter, errorMsg }) => { - expect(() => ctx.getRoot()).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); - }; - - /** 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 clientConfigurationScenarios = [ - { - description: 'public API throws missing object modes error when attached without correct modes', - action: async (ctx) => { - const { objects, 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'); - // now simulate missing modes - channel.modes = []; - - expectAccessBatchApiToThrow({ ctx, map, counter, errorMsg: '"object_subscribe" channel mode' }); - 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' }); - }, - }, - - { - 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; - - // obtain batch context with valid modes first - await objects.batch((ctx) => { - const map = ctx.getRoot().get('map'); - const counter = ctx.getRoot().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'); - channel.channelOptions.modes = []; - - expectAccessBatchApiToThrow({ ctx, map, counter, errorMsg: '"object_subscribe" channel mode' }); - 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' }); - }, - }, - - { - description: 'public API throws invalid channel state error when channel DETACHED', - action: async (ctx) => { - const { objects, 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'); - // 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' }); - }); - - await expectAccessApiToThrow({ - objects, - 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; - - // obtain batch context with valid channel state first - await objects.batch((ctx) => { - const map = ctx.getRoot().get('map'); - const counter = ctx.getRoot().get('counter'); - // 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' }); - }); - - await expectAccessApiToThrow({ - objects, - 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; - - // obtain batch context with valid channel state first - await objects.batch((ctx) => { - const map = ctx.getRoot().get('map'); - const counter = ctx.getRoot().get('counter'); - // now simulate channel state change - helper.recordPrivateApi('call.channel.requestState'); - channel.requestState('suspended'); - - expectWriteBatchApiToThrow({ ctx, map, counter, errorMsg: 'failed as channel state is suspended' }); - }); - - await expectWriteApiToThrow({ - objects, - map, - counter, - errorMsg: 'failed as channel state is suspended', - }); - }, - }, - - { - description: 'public write API throws invalid channel option when "echoMessages" is disabled', - action: async (ctx) => { - const { objects, 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'); - // now simulate echoMessages was disabled - helper.recordPrivateApi('write.realtime.options.echoMessages'); - client.options.echoMessages = false; - - expectWriteBatchApiToThrow({ ctx, map, counter, errorMsg: '"echoMessages" client option' }); - }); - - await expectWriteApiToThrow({ objects, map, counter, errorMsg: '"echoMessages" client option' }); - }, - }, - ]; - - /** @nospec */ - forScenarios(this, clientConfigurationScenarios, async function (helper, scenario, clientOptions, channelName) { - const objectsHelper = new ObjectsHelper(helper); - const client = RealtimeWithObjects(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 objects = channel.objects; - - await channel.attach(); - const root = await objects.getRoot(); - - const objectsCreatedPromise = Promise.all([ - waitForMapKeyUpdate(root, 'map'), - waitForMapKeyUpdate(root, 'counter'), - ]); - const map = await objects.createMap(); - const counter = await objects.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 }); - }, client); - }); - - /** - * @spec TO3l8 - * @spec RSL1i - */ - it('object message publish respects connectionDetails.maxMessageSize', async function () { - const helper = this.test.helper; - const client = RealtimeWithObjects(helper); - - await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - await client.connection.once('connected'); - - const connectionManager = client.connection.connectionManager; - const connectionDetails = connectionManager.connectionDetails; - const connectionDetailsPromise = connectionManager.once('connectiondetails'); - - helper.recordPrivateApi('write.connectionManager.connectionDetails.maxMessageSize'); - connectionDetails.maxMessageSize = 64; - - helper.recordPrivateApi('call.connectionManager.activeProtocol.getTransport'); - helper.recordPrivateApi('call.transport.onProtocolMessage'); - helper.recordPrivateApi('call.makeProtocolMessageFromDeserialized'); - // forge lower maxMessageSize - connectionManager.activeProtocol.getTransport().onProtocolMessage( - createPM({ - action: 4, // CONNECTED - connectionDetails, - }), - ); - - helper.recordPrivateApi('listen.connectionManager.connectiondetails'); - await connectionDetailsPromise; - - const channel = client.channels.get('channel', channelOptionsWithObjects()); - const objects = channel.objects; - - await channel.attach(); - const root = await objects.getRoot(); - - const data = new Array(100).fill('a').join(''); - const error = await expectToThrowAsync( - async () => root.set('key', data), - 'Maximum size of object messages that can be published at once exceeded', - ); - - expect(error.code).to.equal(40009, 'Check maximum size of messages error has correct error code'); - }, client); - }); - - describe('ObjectMessage message size', () => { - const objectMessageSizeScenarios = [ - { - description: 'client id', - message: objectMessageFromValues({ - clientId: 'my-client', - }), - expected: Utils.dataSizeBytes('my-client'), - }, - { - description: 'extras', - message: objectMessageFromValues({ - extras: { foo: 'bar' }, - }), - expected: Utils.dataSizeBytes('{"foo":"bar"}'), - }, - { - description: 'object id', - message: objectMessageFromValues({ - operation: { objectId: 'object-id' }, - }), - expected: 0, - }, - { - description: 'nonce', - message: objectMessageFromValues({ - operation: { nonce: '1234567890' }, - }), - expected: 0, - }, - { - description: 'initial value', - message: objectMessageFromValues({ - operation: { initialValue: JSON.stringify({ counter: { count: 1 } }) }, - }), - expected: 0, - }, - { - description: 'map create op no payload', - message: objectMessageFromValues({ - operation: { action: 0, objectId: 'object-id' }, - }), - expected: 0, - }, - { - description: 'map create op with object id payload', - message: objectMessageFromValues({ - operation: { - action: 0, - objectId: 'object-id', - map: { - semantics: 0, - entries: { 'key-1': { tombstone: false, data: { objectId: 'another-object-id' } } }, - }, - }, - }), - expected: Utils.dataSizeBytes('key-1'), - }, - { - description: 'map create op with string payload', - message: objectMessageFromValues({ - operation: { - action: 0, - objectId: 'object-id', - map: { semantics: 0, entries: { 'key-1': { tombstone: false, data: { value: 'a string' } } } }, - }, - }), - expected: Utils.dataSizeBytes('key-1') + Utils.dataSizeBytes('a string'), - }, - { - description: 'map create op with bytes payload', - message: objectMessageFromValues({ - operation: { - action: 0, - objectId: 'object-id', - map: { - semantics: 0, - entries: { 'key-1': { tombstone: false, data: { value: BufferUtils.utf8Encode('my-value') } } }, - }, - }, - }), - expected: Utils.dataSizeBytes('key-1') + Utils.dataSizeBytes(BufferUtils.utf8Encode('my-value')), - }, - { - description: 'map create op with boolean payload', - message: objectMessageFromValues({ - operation: { - action: 0, - objectId: 'object-id', - map: { - semantics: 0, - entries: { - 'key-1': { tombstone: false, data: { value: true } }, - 'key-2': { tombstone: false, data: { value: false } }, - }, - }, - }, - }), - expected: Utils.dataSizeBytes('key-1') + Utils.dataSizeBytes('key-2') + 2, - }, - { - description: 'map create op with double payload', - message: objectMessageFromValues({ - operation: { - action: 0, - objectId: 'object-id', - map: { - semantics: 0, - entries: { - 'key-1': { tombstone: false, data: { value: 123.456 } }, - 'key-2': { tombstone: false, data: { value: 0 } }, - }, - }, - }, - }), - expected: Utils.dataSizeBytes('key-1') + Utils.dataSizeBytes('key-2') + 16, - }, - { - description: 'map create op with object payload', - message: objectMessageFromValues({ - operation: { - action: 0, - objectId: 'object-id', - map: { - semantics: 0, - entries: { 'key-1': { tombstone: false, data: { value: { foo: 'bar' } } } }, - }, - }, - }), - expected: Utils.dataSizeBytes('key-1') + JSON.stringify({ foo: 'bar' }).length, - }, - { - description: 'map create op with array payload', - message: objectMessageFromValues({ - operation: { - action: 0, - objectId: 'object-id', - map: { - semantics: 0, - entries: { 'key-1': { tombstone: false, data: { value: ['foo', 'bar', 'baz'] } } }, - }, - }, - }), - expected: Utils.dataSizeBytes('key-1') + JSON.stringify(['foo', 'bar', 'baz']).length, - }, - { - description: 'map remove op', - message: objectMessageFromValues({ - operation: { action: 2, objectId: 'object-id', mapOp: { key: 'my-key' } }, - }), - expected: Utils.dataSizeBytes('my-key'), - }, - { - description: 'map set operation value=objectId', - message: objectMessageFromValues({ - operation: { - action: 1, - objectId: 'object-id', - mapOp: { key: 'my-key', data: { objectId: 'another-object-id' } }, - }, - }), - expected: Utils.dataSizeBytes('my-key'), - }, - { - description: 'map set operation value=string', - message: objectMessageFromValues({ - operation: { action: 1, objectId: 'object-id', mapOp: { key: 'my-key', data: { value: 'my-value' } } }, - }), - expected: Utils.dataSizeBytes('my-key') + Utils.dataSizeBytes('my-value'), - }, - { - description: 'map set operation value=bytes', - message: objectMessageFromValues({ - operation: { - action: 1, - objectId: 'object-id', - mapOp: { key: 'my-key', data: { value: BufferUtils.utf8Encode('my-value') } }, - }, - }), - expected: Utils.dataSizeBytes('my-key') + Utils.dataSizeBytes(BufferUtils.utf8Encode('my-value')), - }, - { - description: 'map set operation value=boolean true', - message: objectMessageFromValues({ - operation: { action: 1, objectId: 'object-id', mapOp: { key: 'my-key', data: { value: true } } }, - }), - expected: Utils.dataSizeBytes('my-key') + 1, - }, - { - description: 'map set operation value=boolean false', - message: objectMessageFromValues({ - operation: { action: 1, objectId: 'object-id', mapOp: { key: 'my-key', data: { value: false } } }, - }), - expected: Utils.dataSizeBytes('my-key') + 1, - }, - { - description: 'map set operation value=double', - message: objectMessageFromValues({ - operation: { action: 1, objectId: 'object-id', mapOp: { key: 'my-key', data: { value: 123.456 } } }, - }), - expected: Utils.dataSizeBytes('my-key') + 8, - }, - { - description: 'map set operation value=double 0', - message: objectMessageFromValues({ - operation: { action: 1, objectId: 'object-id', mapOp: { key: 'my-key', data: { value: 0 } } }, - }), - expected: Utils.dataSizeBytes('my-key') + 8, - }, - { - description: 'map set operation value=json-object', - message: objectMessageFromValues({ - operation: { - action: 1, - objectId: 'object-id', - mapOp: { key: 'my-key', data: { value: { foo: 'bar' } } }, - }, - }), - expected: Utils.dataSizeBytes('my-key') + JSON.stringify({ foo: 'bar' }).length, - }, - { - description: 'map set operation value=json-array', - message: objectMessageFromValues({ - operation: { - action: 1, - objectId: 'object-id', - mapOp: { key: 'my-key', data: { value: ['foo', 'bar', 'baz'] } }, - }, - }), - expected: Utils.dataSizeBytes('my-key') + JSON.stringify(['foo', 'bar', 'baz']).length, - }, - { - description: 'map object', - message: objectMessageFromValues({ - object: { - objectId: 'object-id', - map: { - semantics: 0, - entries: { - 'key-1': { tombstone: false, data: { value: 'a string' } }, - 'key-2': { tombstone: true, data: { value: 'another string' } }, - }, - }, - createOp: { - action: 0, - objectId: 'object-id', - map: { semantics: 0, entries: { 'key-3': { tombstone: false, data: { value: 'third string' } } } }, - }, - siteTimeserials: { aaa: lexicoTimeserial('aaa', 111, 111, 1) }, // shouldn't be counted - tombstone: false, - }, - }), - expected: - Utils.dataSizeBytes('key-1') + - Utils.dataSizeBytes('a string') + - Utils.dataSizeBytes('key-2') + - Utils.dataSizeBytes('another string') + - Utils.dataSizeBytes('key-3') + - Utils.dataSizeBytes('third string'), - }, - { - description: 'counter create op no payload', - message: objectMessageFromValues({ - operation: { action: 3, objectId: 'object-id' }, - }), - expected: 0, - }, - { - description: 'counter create op with payload', - message: objectMessageFromValues({ - operation: { action: 3, objectId: 'object-id', counter: { count: 1234567 } }, - }), - expected: 8, - }, - { - description: 'counter inc op', - message: objectMessageFromValues({ - operation: { action: 4, objectId: 'object-id', counterOp: { amount: 123.456 } }, - }), - expected: 8, - }, - { - description: 'counter object', - message: objectMessageFromValues({ - object: { - objectId: 'object-id', - counter: { count: 1234567 }, - createOp: { - action: 3, - objectId: 'object-id', - counter: { count: 9876543 }, - }, - siteTimeserials: { aaa: lexicoTimeserial('aaa', 111, 111, 1) }, // shouldn't be counted - tombstone: false, - }, - }), - expected: 8 + 8, - }, - ]; - - /** @nospec */ - forScenarios(this, objectMessageSizeScenarios, function (helper, scenario) { - const client = RealtimeWithObjects(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 - helper.recordPrivateApi('call.ObjectMessage.fromValues'); // was called by a scenario to create an ObjectMessage instance - helper.recordPrivateApi('call.Utils.dataSizeBytes'); // was called by a scenario to calculated the expected byte size - helper.recordPrivateApi('call.ObjectMessage.getMessageSize'); - expect(encodedMessage.getMessageSize()).to.equal(scenario.expected); - }); - }); - }); - - /** @nospec */ - it('can attach to channel with object modes', async function () { - const helper = this.test.helper; - const client = helper.AblyRealtime(); - - await helper.monitorConnectionThenCloseAndFinishAsync(async () => { - const objectsModes = ['object_subscribe', 'object_publish']; - const channelOptions = { modes: objectsModes }; - const channel = client.channels.get('channel', channelOptions); - - await channel.attach(); - - helper.recordPrivateApi('read.channel.channelOptions'); - expect(channel.channelOptions).to.deep.equal(channelOptions, 'Check expected channel options'); - expect(channel.modes).to.deep.equal(objectsModes, 'Check expected modes'); - }, client); - }); - - /** @nospec */ - describe('Sync events', () => { - /** - * Helper function to inject an ATTACHED protocol message with or without HAS_OBJECTS flag - */ - async function injectAttachedMessage(helper, channel, hasObjects) { - helper.recordPrivateApi('call.connectionManager.activeProtocol.getTransport'); - helper.recordPrivateApi('call.transport.onProtocolMessage'); - helper.recordPrivateApi('call.makeProtocolMessageFromDeserialized'); - const transport = channel.client.connection.connectionManager.activeProtocol.getTransport(); - const pm = createPM({ - action: 11, // ATTACHED - channel: channel.name, - flags: hasObjects ? 1 << 7 : 0, // HAS_OBJECTS flag is bit 7 - }); - await transport.onProtocolMessage(pm); - } - - const syncEventsScenarios = [ - // 1. ATTACHED with HAS_OBJECTS false - - { - description: - 'The first ATTACHED should always provoke a SYNCING even when HAS_OBJECTS is false, so that the SYNCED is preceded by SYNCING', - channelEvents: [{ type: 'attached', hasObjects: false }], - expectedSyncEvents: ['syncing', 'synced'], - }, - - { - description: 'ATTACHED with HAS_OBJECTS false once SYNCED emits SYNCING and then SYNCED', - channelEvents: [ - { type: 'attached', hasObjects: false }, - { type: 'attached', hasObjects: false }, - ], - expectedSyncEvents: [ - 'syncing', - 'synced', // The initial SYNCED - 'syncing', - 'synced', // From the subsequent ATTACHED - ], - }, - - { - description: - "If we're in SYNCING awaiting an OBJECT_SYNC but then instead get an ATTACHED with HAS_OBJECTS false, we should emit a SYNCED", - channelEvents: [ - { type: 'attached', hasObjects: true }, - { type: 'attached', hasObjects: false }, - ], - expectedSyncEvents: ['syncing', 'synced'], - }, - - // 2. ATTACHED with HAS_OBJECTS true - - { - description: 'An initial ATTACHED with HAS_OBJECTS true provokes a SYNCING', - channelEvents: [{ type: 'attached', hasObjects: true }], - expectedSyncEvents: ['syncing'], - }, - - { - description: - "ATTACHED with HAS_OBJECTS true when SYNCED should provoke another SYNCING, because we're waiting to receive the updated objects in an OBJECT_SYNC", - channelEvents: [ - { type: 'attached', hasObjects: false }, - { type: 'attached', hasObjects: true }, - ], - expectedSyncEvents: ['syncing', 'synced', 'syncing'], - }, - - { - description: - "If we're in SYNCING awaiting an OBJECT_SYNC but then instead get another ATTACHED with HAS_OBJECTS true, we should remain SYNCING (i.e. not emit another event)", - channelEvents: [ - { type: 'attached', hasObjects: true }, - { type: 'attached', hasObjects: true }, - ], - expectedSyncEvents: ['syncing'], - }, - - // 3. OBJECT_SYNC straight after ATTACHED - - { - description: 'A complete multi-message OBJECT_SYNC sequence after ATTACHED emits SYNCING and then SYNCED', - channelEvents: [ - { type: 'attached', hasObjects: true }, - { type: 'objectSync', channelSerial: 'foo:1' }, - { type: 'objectSync', channelSerial: 'foo:2' }, - { type: 'objectSync', channelSerial: 'foo:' }, - ], - expectedSyncEvents: ['syncing', 'synced'], - }, - - { - description: 'A complete single-message OBJECT_SYNC after ATTACHED emits SYNCING and then SYNCED', - channelEvents: [ - { type: 'attached', hasObjects: true }, - { type: 'objectSync', channelSerial: 'foo:' }, - ], - expectedSyncEvents: ['syncing', 'synced'], - }, - - { - description: 'SYNCED is not emitted midway through a multi-message OBJECT_SYNC sequence', - channelEvents: [ - { type: 'attached', hasObjects: true }, - { type: 'objectSync', channelSerial: 'foo:1' }, - { type: 'objectSync', channelSerial: 'foo:2' }, - ], - expectedSyncEvents: ['syncing'], - }, - - // 4. OBJECT_SYNC when already SYNCED - - { - description: - 'A complete multi-message OBJECT_SYNC sequence when already SYNCED emits SYNCING and then SYNCED', - channelEvents: [ - { type: 'attached', hasObjects: false }, // to get us to SYNCED - { type: 'objectSync', channelSerial: 'foo:1' }, - { type: 'objectSync', channelSerial: 'foo:2' }, - { type: 'objectSync', channelSerial: 'foo:' }, - ], - expectedSyncEvents: [ - 'syncing', - 'synced', // The initial SYNCED - 'syncing', - 'synced', // From the complete OBJECT_SYNC - ], - }, - - { - description: 'A complete single-message OBJECT_SYNC when already SYNCED emits SYNCING and then SYNCED', - channelEvents: [ - { type: 'attached', hasObjects: false }, // to get us to SYNCED - { type: 'objectSync', channelSerial: 'foo:' }, - ], - expectedSyncEvents: [ - 'syncing', - 'synced', // The initial SYNCED - 'syncing', - 'synced', // From the complete OBJECT_SYNC - ], - }, - - // 5. New sync sequence in the middle of a sync sequence - - { - description: 'A new OBJECT_SYNC sequence in the middle of a sync sequence does not provoke another SYNCING', - channelEvents: [ - { type: 'attached', hasObjects: true }, - { type: 'objectSync', channelSerial: 'foo:1' }, - { type: 'objectSync', channelSerial: 'foo:2' }, - { type: 'objectSync', channelSerial: 'bar:1' }, - ], - expectedSyncEvents: ['syncing'], - }, - ]; - - forScenarios(this, syncEventsScenarios, async function (helper, scenario, clientOptions, channelName) { - const client = RealtimeWithObjects(helper, clientOptions); - const objectsHelper = new ObjectsHelper(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; - - // Track received sync events - const receivedSyncEvents = []; - - // Subscribe to syncing and synced events - objects.on('syncing', () => { - receivedSyncEvents.push('syncing'); - }); - objects.on('synced', () => { - receivedSyncEvents.push('synced'); - }); - - // Apply the sequence of channel events described by the scenario - for (const channelEvent of scenario.channelEvents) { - if (channelEvent.type === 'attached') { - await injectAttachedMessage(helper, channel, channelEvent.hasObjects); - } else if (channelEvent.type === 'objectSync') { - await objectsHelper.processObjectStateMessageOnChannel({ - channel, - syncSerial: channelEvent.channelSerial, - }); - } - } - - // Verify the expected sequence of sync events - expect(receivedSyncEvents).to.deep.equal( - scenario.expectedSyncEvents, - 'Check sync events match expected sequence', - ); - }, client); - }); - }); - }); -}); 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 new file mode 100644 index 0000000000..fbc3eb23ce --- /dev/null +++ b/tsconfig.typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/platform/react-hooks", "test/package", "liveobjects.d.mts"] +} diff --git a/typedoc.json b/typedoc.json index bdbe58b4dc..3a7708bd4b 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", "liveobjects.d.ts"], + "tsconfig": "tsconfig.typedoc.json", "out": "typedoc/generated", "readme": "typedoc/landing-page.md", "treatWarningsAsErrors": true, @@ -20,6 +21,5 @@ "TypeAlias", "Variable", "Namespace" - ], - "intentionallyNotExported": ["__global.AblyObjectsTypes"] + ] }