diff --git a/src/plugins/liveobjects/livecounter.ts b/src/plugins/liveobjects/livecounter.ts index 95b8dbe52..10542f067 100644 --- a/src/plugins/liveobjects/livecounter.ts +++ b/src/plugins/liveobjects/livecounter.ts @@ -36,6 +36,56 @@ export class LiveCounter extends LiveObject return this._dataRef.data; } + /** + * Send a COUNTER_INC operation to the realtime system to increment a value on this LiveCounter object. + * + * This does not modify the underlying data of this LiveCounter object. Instead, the change will be applied when + * the published COUNTER_INC operation is echoed back to the client and applied to the object following the regular + * operation application procedure. + * + * @returns A promise which resolves upon receiving the ACK message for the published operation message. + */ + async increment(amount: number): Promise { + const stateMessage = this.createCounterIncMessage(amount); + return this._liveObjects.publish([stateMessage]); + } + + /** + * @internal + */ + createCounterIncMessage(amount: number): StateMessage { + if (typeof amount !== 'number' || !isFinite(amount)) { + throw new this._client.ErrorInfo('Counter value increment should be a valid number', 40013, 400); + } + + const stateMessage = StateMessage.fromValues( + { + operation: { + action: StateOperationAction.COUNTER_INC, + objectId: this.getObjectId(), + counterOp: { amount }, + }, + }, + this._client.Utils, + this._client.MessageEncoding, + ); + + return stateMessage; + } + + /** + * Alias for calling {@link LiveCounter.increment | LiveCounter.increment(-amount)} + */ + async decrement(amount: number): Promise { + // 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' || !isFinite(amount)) { + throw new this._client.ErrorInfo('Counter value decrement should be a valid number', 40013, 400); + } + + return this.increment(-amount); + } + /** * @internal */ @@ -55,7 +105,7 @@ export class LiveCounter extends LiveObject this._client.logger, this._client.Logger.LOG_MICRO, 'LiveCounter.applyOperation()', - `skipping ${op.action} op: op timeserial ${opOriginTimeserial.toString()} <= site timeserial ${this._siteTimeserials[opSiteCode]?.toString()}; objectId=${this._objectId}`, + `skipping ${op.action} op: op timeserial ${opOriginTimeserial.toString()} <= site timeserial ${this._siteTimeserials[opSiteCode]?.toString()}; objectId=${this.getObjectId()}`, ); return; } @@ -202,7 +252,7 @@ export class LiveCounter extends LiveObject this._client.logger, this._client.Logger.LOG_MICRO, 'LiveCounter._applyCounterCreate()', - `skipping applying COUNTER_CREATE op on a counter instance as it was already applied before; objectId=${this._objectId}`, + `skipping applying COUNTER_CREATE op on a counter instance as it was already applied before; objectId=${this.getObjectId()}`, ); return { noop: true }; } diff --git a/src/plugins/liveobjects/livemap.ts b/src/plugins/liveobjects/livemap.ts index 378ef3b58..79b291d3d 100644 --- a/src/plugins/liveobjects/livemap.ts +++ b/src/plugins/liveobjects/livemap.ts @@ -153,6 +153,98 @@ export class LiveMap extends LiveObject(key: TKey, value: T[TKey]): Promise { + const stateMessage = this.createMapSetMessage(key, value); + return this._liveObjects.publish([stateMessage]); + } + + /** + * @internal + */ + createMapSetMessage(key: TKey, value: T[TKey]): StateMessage { + if (typeof key !== 'string') { + throw new this._client.ErrorInfo('Map key should be string', 40013, 400); + } + + if ( + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' && + !this._client.Platform.BufferUtils.isBuffer(value) && + !(value instanceof LiveObject) + ) { + throw new this._client.ErrorInfo('Map value data type is unsupported', 40013, 400); + } + + const stateData: StateData = + value instanceof LiveObject + ? ({ objectId: value.getObjectId() } as ObjectIdStateData) + : ({ value } as ValueStateData); + + const stateMessage = StateMessage.fromValues( + { + operation: { + action: StateOperationAction.MAP_SET, + objectId: this.getObjectId(), + mapOp: { + key, + data: stateData, + }, + }, + }, + this._client.Utils, + this._client.MessageEncoding, + ); + + return stateMessage; + } + + /** + * Send a MAP_REMOVE operation to the realtime system to tombstone a key on this LiveMap object. + * + * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when + * the published MAP_REMOVE operation is echoed back to the client and applied to the object following the regular + * operation application procedure. + * + * @returns A promise which resolves upon receiving the ACK message for the published operation message. + */ + async remove(key: TKey): Promise { + const stateMessage = this.createMapRemoveMessage(key); + return this._liveObjects.publish([stateMessage]); + } + + /** + * @internal + */ + createMapRemoveMessage(key: TKey): StateMessage { + if (typeof key !== 'string') { + throw new this._client.ErrorInfo('Map key should be string', 40013, 400); + } + + const stateMessage = StateMessage.fromValues( + { + operation: { + action: StateOperationAction.MAP_REMOVE, + objectId: this.getObjectId(), + mapOp: { key }, + }, + }, + this._client.Utils, + this._client.MessageEncoding, + ); + + return stateMessage; + } + /** * @internal */ @@ -172,7 +264,7 @@ export class LiveMap extends LiveObject extends LiveObject extends LiveObject extends LiveObject { + if (!this._channel.connectionManager.activeState()) { + throw this._channel.connectionManager.getError(); + } + + if (this._channel.state === 'failed' || this._channel.state === 'suspended') { + throw this._client.ErrorInfo.fromValues(this._channel.invalidStateError()); + } + + stateMessages.forEach((x) => StateMessage.encode(x, this._client.MessageEncoding)); + + return this._channel.sendState(stateMessages); + } + private _startNewSync(syncId?: string, syncCursor?: string): void { // need to discard all buffered state operation messages on new sync start this._bufferedStateOperations = [];