From de71ceffc89c91b131a169a47385ff45ad2751e3 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Mon, 27 Jan 2025 03:31:52 +0000 Subject: [PATCH 1/4] Refactor maximum size of messages exceeded error message --- src/common/lib/client/realtimechannel.ts | 6 +----- src/common/lib/client/restchannel.ts | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/common/lib/client/realtimechannel.ts b/src/common/lib/client/realtimechannel.ts index f7b1bd44b..179f38e5d 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -265,11 +265,7 @@ class RealtimeChannel extends EventEmitter { const size = getMessagesSize(messages); if (size > maxMessageSize) { throw new ErrorInfo( - 'Maximum size of messages that can be published at once exceeded ( was ' + - size + - ' bytes; limit is ' + - maxMessageSize + - ' bytes)', + `Maximum size of messages that can be published at once exceeded (was ${size} bytes; limit is ${maxMessageSize} bytes)`, 40009, 400, ); diff --git a/src/common/lib/client/restchannel.ts b/src/common/lib/client/restchannel.ts index e27133c6e..9a5ad74ef 100644 --- a/src/common/lib/client/restchannel.ts +++ b/src/common/lib/client/restchannel.ts @@ -117,11 +117,7 @@ class RestChannel { maxMessageSize = options.maxMessageSize; if (size > maxMessageSize) { throw new ErrorInfo( - 'Maximum size of messages that can be published at once exceeded ( was ' + - size + - ' bytes; limit is ' + - maxMessageSize + - ' bytes)', + `Maximum size of messages that can be published at once exceeded (was ${size} bytes; limit is ${maxMessageSize} bytes)`, 40009, 400, ); From e6aa40eb2f97714b22e89a61b7d043c2ebd2d3a9 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Mon, 27 Jan 2025 03:33:42 +0000 Subject: [PATCH 2/4] Add missing `helper.recordPrivateApi` calls to live objects tests --- test/realtime/live_objects.test.js | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/test/realtime/live_objects.test.js b/test/realtime/live_objects.test.js index d7bc2cce2..ca38ea5ac 100644 --- a/test/realtime/live_objects.test.js +++ b/test/realtime/live_objects.test.js @@ -438,6 +438,8 @@ define(['ably', 'shared_helper', 'chai', 'live_objects', 'live_objects_helper'], 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'), @@ -445,6 +447,8 @@ define(['ably', 'shared_helper', 'chai', 'live_objects', 'live_objects_helper'], ), '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', @@ -688,7 +692,7 @@ define(['ably', 'shared_helper', 'chai', 'live_objects', 'live_objects_helper'], { description: 'can apply MAP_CREATE with primitives state operation messages', action: async (ctx) => { - const { root, liveObjectsHelper, channelName } = ctx; + const { root, liveObjectsHelper, channelName, helper } = ctx; // LiveObjects 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. @@ -729,6 +733,8 @@ define(['ably', 'shared_helper', 'chai', 'live_objects', 'live_objects_helper'], Object.entries(fixture.entries ?? {}).forEach(([key, keyData]) => { if (keyData.data.encoding) { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); expect( BufferUtils.areBuffersEqual(mapObj.get(key), BufferUtils.base64Decode(keyData.data.value)), `Check map "${mapKey}" has correct value for "${key}" key`, @@ -902,7 +908,7 @@ define(['ably', 'shared_helper', 'chai', 'live_objects', 'live_objects_helper'], { description: 'can apply MAP_SET with primitives state operation messages', action: async (ctx) => { - const { root, liveObjectsHelper, channelName } = ctx; + const { root, liveObjectsHelper, channelName, helper } = ctx; // check root is empty before ops primitiveKeyData.forEach((keyData) => { @@ -929,6 +935,8 @@ define(['ably', 'shared_helper', 'chai', 'live_objects', 'live_objects_helper'], // check everything is applied correctly primitiveKeyData.forEach((keyData) => { if (keyData.data.encoding) { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); expect( BufferUtils.areBuffersEqual(root.get(keyData.key), BufferUtils.base64Decode(keyData.data.value)), `Check root has correct value for "${keyData.key}" key after MAP_SET op`, @@ -1785,7 +1793,7 @@ define(['ably', 'shared_helper', 'chai', 'live_objects', 'live_objects_helper'], { description: 'buffered state operation messages are applied when STATE_SYNC sequence ends', action: async (ctx) => { - const { root, liveObjectsHelper, channel } = ctx; + const { root, liveObjectsHelper, channel, helper } = ctx; // start new sync sequence with a cursor so client will wait for the next STATE_SYNC messages await liveObjectsHelper.processStateObjectMessageOnChannel({ @@ -1814,6 +1822,8 @@ define(['ably', 'shared_helper', 'chai', 'live_objects', 'live_objects_helper'], // check everything is applied correctly primitiveKeyData.forEach((keyData) => { if (keyData.data.encoding) { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); expect( BufferUtils.areBuffersEqual(root.get(keyData.key), BufferUtils.base64Decode(keyData.data.value)), `Check root has correct value for "${keyData.key}" key after STATE_SYNC has ended and buffered operations are applied`, @@ -2013,7 +2023,7 @@ define(['ably', 'shared_helper', 'chai', 'live_objects', 'live_objects_helper'], description: 'subsequent state operation messages are applied immediately after STATE_SYNC ended and buffers are applied', action: async (ctx) => { - const { root, liveObjectsHelper, channel, channelName } = ctx; + const { root, liveObjectsHelper, channel, channelName, helper } = ctx; // start new sync sequence with a cursor so client will wait for the next STATE_SYNC messages await liveObjectsHelper.processStateObjectMessageOnChannel({ @@ -2052,6 +2062,8 @@ define(['ably', 'shared_helper', 'chai', 'live_objects', 'live_objects_helper'], // check buffered operations are applied, as well as the most recent operation outside of the STATE_SYNC is applied primitiveKeyData.forEach((keyData) => { if (keyData.data.encoding) { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); expect( BufferUtils.areBuffersEqual(root.get(keyData.key), BufferUtils.base64Decode(keyData.data.value)), `Check root has correct value for "${keyData.key}" key after STATE_SYNC has ended and buffered operations are applied`, @@ -2279,10 +2291,11 @@ define(['ably', 'shared_helper', 'chai', 'live_objects', 'live_objects_helper'], { description: 'LiveMap.set sends MAP_SET operation with primitive values', action: async (ctx) => { - const { root } = ctx; + const { root, helper } = ctx; await Promise.all( primitiveKeyData.map(async (keyData) => { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); const value = keyData.data.encoding ? BufferUtils.base64Decode(keyData.data.value) : keyData.data.value; await root.set(keyData.key, value); }), @@ -2291,6 +2304,8 @@ define(['ably', 'shared_helper', 'chai', 'live_objects', 'live_objects_helper'], // check everything is applied correctly primitiveKeyData.forEach((keyData) => { if (keyData.data.encoding) { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); expect( BufferUtils.areBuffersEqual(root.get(keyData.key), BufferUtils.base64Decode(keyData.data.value)), `Check root has correct value for "${keyData.key}" key after LiveMap.set call`, @@ -2602,12 +2617,13 @@ define(['ably', 'shared_helper', 'chai', 'live_objects', 'live_objects_helper'], { description: 'LiveObjects.createMap sends MAP_CREATE operation with primitive values', action: async (ctx) => { - const { liveObjects } = ctx; + const { liveObjects, helper } = ctx; const maps = await Promise.all( primitiveMapsFixtures.map(async (mapFixture) => { const entries = mapFixture.entries ? Object.entries(mapFixture.entries).reduce((acc, [key, keyData]) => { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); const value = keyData.data.encoding ? BufferUtils.base64Decode(keyData.data.value) : keyData.data.value; @@ -2634,6 +2650,8 @@ define(['ably', 'shared_helper', 'chai', 'live_objects', 'live_objects_helper'], Object.entries(fixture.entries ?? {}).forEach(([key, keyData]) => { if (keyData.data.encoding) { + helper.recordPrivateApi('call.BufferUtils.base64Decode'); + helper.recordPrivateApi('call.BufferUtils.areBuffersEqual'); expect( BufferUtils.areBuffersEqual(map.get(key), BufferUtils.base64Decode(keyData.data.value)), `Check map #${i + 1} has correct value for "${key}" key`, From 85d988c4e89c257b171a42cb64032037c7271f3e Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Mon, 27 Jan 2025 03:32:17 +0000 Subject: [PATCH 3/4] Update `Utils.dataSizeBytes` function to include numbers, booleans and more generic Buffer type This is in preparation for message size calculation for StateMessages, which can have numbers and booleans as user provided data [1]. Also should use platform agnostic `Bufferlike` type provided by the Platform module to have better support for node.js/browser buffers. [1] https://ably.atlassian.net/browse/DTP-1118 --- src/common/lib/util/utils.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/common/lib/util/utils.ts b/src/common/lib/util/utils.ts index b88f9dd4b..6daae712c 100644 --- a/src/common/lib/util/utils.ts +++ b/src/common/lib/util/utils.ts @@ -1,4 +1,4 @@ -import Platform from 'common/platform'; +import Platform, { Bufferlike } from 'common/platform'; import ErrorInfo, { PartialErrorInfo } from 'common/lib/types/errorinfo'; import { ModularPlugins } from '../client/modularplugins'; import { MsgPack } from 'common/types/msgpack'; @@ -279,15 +279,23 @@ export function inspectBody(body: unknown): string { } } -/* Data is assumed to be either a string or a buffer. */ -export function dataSizeBytes(data: string | Buffer): number { +/* Data is assumed to be either a string, a number, a boolean or a buffer. */ +export function dataSizeBytes(data: string | number | boolean | Bufferlike): number { if (Platform.BufferUtils.isBuffer(data)) { return Platform.BufferUtils.byteLength(data); } if (typeof data === 'string') { return Platform.Config.stringByteSize(data); } - throw new Error('Expected input of Utils.dataSizeBytes to be a buffer or string, but was: ' + typeof data); + if (typeof data === 'number') { + return 8; + } + if (typeof data === 'boolean') { + return 1; + } + throw new Error( + `Expected input of Utils.dataSizeBytes to be a string, a number, a boolean or a buffer, but was: ${typeof data}`, + ); } export function cheapRandStr(): string { From 2b600246896319a8ca2ccf5c55aecd47affaeeab Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Mon, 27 Jan 2025 03:30:54 +0000 Subject: [PATCH 4/4] Apply `ConnectionDetails.maxMessageSize` limit when publishing state messages Resolves DTP-1118 --- src/common/lib/client/defaultrealtime.ts | 2 + src/plugins/liveobjects/liveobjects.ts | 9 + src/plugins/liveobjects/statemessage.ts | 117 ++++++++ test/common/modules/private_api_recorder.js | 6 +- test/realtime/live_objects.test.js | 285 +++++++++++++++++++- 5 files changed, 415 insertions(+), 4 deletions(-) diff --git a/src/common/lib/client/defaultrealtime.ts b/src/common/lib/client/defaultrealtime.ts index 77854711a..7b19c570e 100644 --- a/src/common/lib/client/defaultrealtime.ts +++ b/src/common/lib/client/defaultrealtime.ts @@ -19,6 +19,7 @@ import { import { Http } from 'common/types/http'; import Defaults from '../util/defaults'; import Logger from '../util/logger'; +import { MessageEncoding } from '../types/message'; /** `DefaultRealtime` is the class that the non tree-shakable version of the SDK exports as `Realtime`. It ensures that this version of the SDK includes all of the functionality which is optionally available in the tree-shakable version. @@ -71,4 +72,5 @@ export class DefaultRealtime extends BaseRealtime { // Used by tests static _Http = Http; static _PresenceMap = PresenceMap; + static _MessageEncoding = MessageEncoding; } diff --git a/src/plugins/liveobjects/liveobjects.ts b/src/plugins/liveobjects/liveobjects.ts index 3d6286147..46108a77d 100644 --- a/src/plugins/liveobjects/liveobjects.ts +++ b/src/plugins/liveobjects/liveobjects.ts @@ -250,6 +250,15 @@ export class LiveObjects { } stateMessages.forEach((x) => StateMessage.encode(x, this._client.MessageEncoding)); + const maxMessageSize = this._client.options.maxMessageSize; + const size = stateMessages.reduce((acc, msg) => acc + msg.getMessageSize(), 0); + if (size > maxMessageSize) { + throw new this._client.ErrorInfo( + `Maximum size of state messages that can be published at once exceeded (was ${size} bytes; limit is ${maxMessageSize} bytes)`, + 40009, + 400, + ); + } return this._channel.sendState(stateMessages); } diff --git a/src/plugins/liveobjects/statemessage.ts b/src/plugins/liveobjects/statemessage.ts index 77fdc5cba..8bceed1e2 100644 --- a/src/plugins/liveobjects/statemessage.ts +++ b/src/plugins/liveobjects/statemessage.ts @@ -445,4 +445,121 @@ export class StateMessage { return result; } + + getMessageSize(): number { + let size = 0; + + size += this.clientId?.length ?? 0; + if (this.operation) { + size += this._getStateOperationSize(this.operation); + } + if (this.object) { + size += this._getStateObjectSize(this.object); + } + if (this.extras) { + size += JSON.stringify(this.extras).length; + } + + return size; + } + + private _getStateOperationSize(operation: StateOperation): number { + let size = 0; + + if (operation.mapOp) { + size += this._getStateMapOpSize(operation.mapOp); + } + if (operation.counterOp) { + size += this._getStateCounterOpSize(operation.counterOp); + } + if (operation.map) { + size += this._getStateMapSize(operation.map); + } + if (operation.counter) { + size += this._getStateCounterSize(operation.counter); + } + + return size; + } + + private _getStateObjectSize(obj: StateObject): number { + let size = 0; + + if (obj.map) { + size += this._getStateMapSize(obj.map); + } + if (obj.counter) { + size += this._getStateCounterSize(obj.counter); + } + if (obj.createOp) { + size += this._getStateOperationSize(obj.createOp); + } + + return size; + } + + private _getStateMapSize(map: StateMap): number { + let size = 0; + + Object.entries(map.entries ?? {}).forEach(([key, entry]) => { + size += key?.length ?? 0; + if (entry) { + size += this._getStateMapEntrySize(entry); + } + }); + + return size; + } + + private _getStateCounterSize(counter: StateCounter): number { + if (counter.count == null) { + return 0; + } + + return 8; + } + + private _getStateMapEntrySize(entry: StateMapEntry): number { + let size = 0; + + if (entry.data) { + size += this._getStateDataSize(entry.data); + } + + return size; + } + + private _getStateMapOpSize(mapOp: StateMapOp): number { + let size = 0; + + size += mapOp.key?.length ?? 0; + + if (mapOp.data) { + size += this._getStateDataSize(mapOp.data); + } + + return size; + } + + private _getStateCounterOpSize(operation: StateCounterOp): number { + if (operation.amount == null) { + return 0; + } + + return 8; + } + + private _getStateDataSize(data: StateData): number { + let size = 0; + + if (data.value) { + size += this._getStateValueSize(data.value); + } + + return size; + } + + private _getStateValueSize(value: StateValue): number { + return this._utils.dataSizeBytes(value); + } } diff --git a/test/common/modules/private_api_recorder.js b/test/common/modules/private_api_recorder.js index 2898aed40..f1f07a2e1 100644 --- a/test/common/modules/private_api_recorder.js +++ b/test/common/modules/private_api_recorder.js @@ -16,6 +16,7 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths) 'call.Defaults.getPort', 'call.Defaults.normaliseOptions', 'call.EventEmitter.emit', + 'call.LiveObject.getObjectId', 'call.LiveObject.isTombstoned', 'call.LiveObjects._liveObjectsPool._onGCInterval', 'call.LiveObjects._liveObjectsPool.get', @@ -25,7 +26,11 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths) 'call.Platform.nextTick', 'call.PresenceMessage.fromValues', 'call.ProtocolMessage.setFlag', + 'call.StateMessage.encode', + 'call.StateMessage.fromValues', + 'call.StateMessage.getMessageSize', 'call.Utils.copy', + 'call.Utils.dataSizeBytes', 'call.Utils.getRetryTime', 'call.Utils.inspectError', 'call.Utils.keysArray', @@ -47,7 +52,6 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths) 'call.http._getHosts', 'call.http.checkConnectivity', 'call.http.doUri', - 'call.LiveObject.getObjectId', 'call.msgpack.decode', 'call.msgpack.encode', 'call.presence._myMembers.put', diff --git a/test/realtime/live_objects.test.js b/test/realtime/live_objects.test.js index ca38ea5ac..f98089bf7 100644 --- a/test/realtime/live_objects.test.js +++ b/test/realtime/live_objects.test.js @@ -9,6 +9,8 @@ define(['ably', 'shared_helper', 'chai', 'live_objects', 'live_objects_helper'], ) { 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; @@ -58,14 +60,20 @@ define(['ably', 'shared_helper', 'chai', 'live_objects', 'live_objects_helper'], } async function expectToThrowAsync(fn, errorStr) { - let verifiedError = false; + let savedError; try { await fn(); } catch (error) { expect(error.message).to.have.string(errorStr); - verifiedError = true; + savedError = error; } - expect(verifiedError, 'Expected async function to throw an error').to.be.true; + expect(savedError, 'Expected async function to throw an error').to.exist; + + return savedError; + } + + function stateMessageFromValues(values) { + return LiveObjectsPlugin.StateMessage.fromValues(values, Utils, MessageEncoding); } describe('realtime/live_objects', function () { @@ -3983,6 +3991,277 @@ define(['ably', 'shared_helper', 'chai', 'live_objects', 'live_objects_helper'], await scenario.action({ liveObjects, liveObjectsHelper, channelName, channel, root, map, counter, helper }); }, client); }); + + /** + * @spec TO3l8 + * @spec RSL1i + */ + it('state message publish respects connectionDetails.maxMessageSize', async function () { + const helper = this.test.helper; + const client = RealtimeWithLiveObjects(helper, { clientId: 'test' }); + + await helper.monitorConnectionThenCloseAndFinish(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', channelOptionsWithLiveObjects()); + const liveObjects = channel.liveObjects; + + await channel.attach(); + const root = await liveObjects.getRoot(); + + const data = new Array(100).fill('a').join(''); + const error = await expectRejectedWith( + async () => root.set('key', data), + 'Maximum size of state 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('StateMessage message size', () => { + const stateMessageSizeScenarios = [ + { + description: 'client id', + message: stateMessageFromValues({ + clientId: 'my-client', + }), + expected: Utils.dataSizeBytes('my-client'), + }, + { + description: 'extras', + message: stateMessageFromValues({ + extras: { foo: 'bar' }, + }), + expected: Utils.dataSizeBytes('{"foo":"bar"}'), + }, + { + description: 'object id', + message: stateMessageFromValues({ + operation: { objectId: 'object-id' }, + }), + expected: 0, + }, + { + description: 'map create op no payload', + message: stateMessageFromValues({ + operation: { action: 0, objectId: 'object-id' }, + }), + expected: 0, + }, + { + description: 'map create op with object payload', + message: stateMessageFromValues( + { + operation: { + action: 0, + objectId: 'object-id', + map: { + semantics: 0, + entries: { 'key-1': { tombstone: false, data: { objectId: 'another-object-id' } } }, + }, + }, + }, + MessageEncoding, + ), + expected: Utils.dataSizeBytes('key-1'), + }, + { + description: 'map create op with string payload', + message: stateMessageFromValues( + { + operation: { + action: 0, + objectId: 'object-id', + map: { semantics: 0, entries: { 'key-1': { tombstone: false, data: { value: 'a string' } } } }, + }, + }, + MessageEncoding, + ), + expected: Utils.dataSizeBytes('key-1') + Utils.dataSizeBytes('a string'), + }, + { + description: 'map create op with bytes payload', + message: stateMessageFromValues( + { + operation: { + action: 0, + objectId: 'object-id', + map: { + semantics: 0, + entries: { 'key-1': { tombstone: false, data: { value: BufferUtils.utf8Encode('my-value') } } }, + }, + }, + }, + MessageEncoding, + ), + expected: Utils.dataSizeBytes('key-1') + Utils.dataSizeBytes(BufferUtils.utf8Encode('my-value')), + }, + { + description: 'map create op with boolean payload', + message: stateMessageFromValues( + { + operation: { + action: 0, + objectId: 'object-id', + map: { semantics: 0, entries: { 'key-1': { tombstone: false, data: { value: true } } } }, + }, + }, + MessageEncoding, + ), + expected: Utils.dataSizeBytes('key-1') + 1, + }, + { + description: 'map remove op', + message: stateMessageFromValues({ + operation: { action: 2, objectId: 'object-id', mapOp: { key: 'my-key' } }, + }), + expected: Utils.dataSizeBytes('my-key'), + }, + { + description: 'map set operation value=object', + message: stateMessageFromValues({ + 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: stateMessageFromValues({ + 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: stateMessageFromValues({ + 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', + message: stateMessageFromValues({ + operation: { action: 1, objectId: 'object-id', mapOp: { key: 'my-key', data: { value: true } } }, + }), + expected: Utils.dataSizeBytes('my-key') + 1, + }, + { + description: 'map set operation value=double', + message: stateMessageFromValues({ + operation: { action: 1, objectId: 'object-id', mapOp: { key: 'my-key', data: { value: 123.456 } } }, + }), + expected: Utils.dataSizeBytes('my-key') + 8, + }, + { + description: 'map object', + message: stateMessageFromValues({ + 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: stateMessageFromValues({ + operation: { action: 3, objectId: 'object-id' }, + }), + expected: 0, + }, + { + description: 'counter create op with payload', + message: stateMessageFromValues({ + operation: { action: 3, objectId: 'object-id', counter: { count: 1234567 } }, + }), + expected: 8, + }, + { + description: 'counter inc op', + message: stateMessageFromValues({ + operation: { action: 4, objectId: 'object-id', counterOp: { amount: 123.456 } }, + }), + expected: 8, + }, + { + description: 'counter object', + message: stateMessageFromValues({ + 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(stateMessageSizeScenarios, function (helper, scenario) { + helper.recordPrivateApi('call.StateMessage.encode'); + LiveObjectsPlugin.StateMessage.encode(scenario.message); + helper.recordPrivateApi('call.BufferUtils.utf8Encode'); // was called by a scenario to create buffers + helper.recordPrivateApi('call.StateMessage.fromValues'); // was called by a scenario to create a StateMessage instance + helper.recordPrivateApi('call.Utils.dataSizeBytes'); // was called by a scenario to calculated the expected byte size + helper.recordPrivateApi('call.StateMessage.getMessageSize'); + expect(scenario.message.getMessageSize()).to.equal(scenario.expected); + }); + }); }); /** @nospec */