diff --git a/src/plugins/liveobjects/livecounter.ts b/src/plugins/liveobjects/livecounter.ts index b1e0ec66b6..6bd54a0349 100644 --- a/src/plugins/liveobjects/livecounter.ts +++ b/src/plugins/liveobjects/livecounter.ts @@ -235,6 +235,12 @@ export class LiveCounter extends LiveObject // update will contain the diff between previous value and new value from object state const update = this._updateFromDataDiff(previousDataRef, this._dataRef); + // RTLC14c - _updateFromDataDiff collapses a zero-delta diff (unchanged counter data) to a noop. + // pass it straight through without stamping the object message, mirroring the terminal noop + // return above (RTLC6e). + if (this._isNoopUpdate(update)) { + return update; + } update.objectMessage = objectMessage; return update; @@ -253,11 +259,27 @@ export class LiveCounter extends LiveObject return { data: 0 }; } - protected _updateFromDataDiff(prevDataRef: LiveCounterData, newDataRef: LiveCounterData): LiveCounterUpdate { + protected _updateFromDataDiff( + prevDataRef: LiveCounterData, + newDataRef: LiveCounterData, + ): LiveCounterUpdate | LiveObjectUpdateNoop { const counterDiff = newDataRef.data - prevDataRef.data; + // RTLC14c - as an exception to RTLC14b: if newData equals previousData (the computed delta is 0) + // the counter data did not change, so instead of returning an update return a LiveCounterUpdate + // object with noop set to true (RTLO4b4b), as in RTLC9h. This exception must not be applied when + // the diff is computed for a tombstone per RTLO4e5; LiveObject.tombstone re-synthesizes a + // non-noop update via _createNoChangeUpdate() so the RTLO4b4c3c listener teardown still fires. + if (counterDiff === 0) { + return { noop: true }; + } return { update: { amount: counterDiff }, _type: 'LiveCounterUpdate' }; } + protected _createNoChangeUpdate(): LiveCounterUpdate { + // RTLO4e5 tombstone carve-out (RTLC14c) - a zero-delta no-change update for an already-zero counter + return { update: { amount: 0 }, _type: 'LiveCounterUpdate' }; + } + protected _mergeInitialDataFromCreateOperation( objectOperation: ObjectOperation, msg: ObjectMessage, diff --git a/src/plugins/liveobjects/livemap.ts b/src/plugins/liveobjects/livemap.ts index 4fb23f8f01..7ebade2930 100644 --- a/src/plugins/liveobjects/livemap.ts +++ b/src/plugins/liveobjects/livemap.ts @@ -462,6 +462,12 @@ export class LiveMap = Record> // update will contain the diff between previous value and new value from object state const update = this._updateFromDataDiff(previousDataRef, this._dataRef); + // RTLM22c - _updateFromDataDiff collapses an empty key-diff (no map key changed) to a noop. + // pass it straight through without stamping the object message, mirroring the terminal noop + // return above (RTLM6e). + if (this._isNoopUpdate(update)) { + return update; + } update.objectMessage = objectMessage; return update; @@ -488,7 +494,7 @@ export class LiveMap = Record> * * @internal */ - clearData(): LiveMapUpdate { + clearData(): LiveMapUpdate | LiveObjectUpdateNoop { // 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) { @@ -600,7 +606,10 @@ export class LiveMap = Record> return { data: new Map() }; } - protected _updateFromDataDiff(prevDataRef: LiveMapData, newDataRef: LiveMapData): LiveMapUpdate { + protected _updateFromDataDiff( + prevDataRef: LiveMapData, + newDataRef: LiveMapData, + ): LiveMapUpdate | LiveObjectUpdateNoop { const update: LiveMapUpdate = { update: {}, _type: 'LiveMapUpdate' }; for (const [key, currentEntry] of prevDataRef.data.entries()) { @@ -653,9 +662,24 @@ export class LiveMap = Record> } } + // RTLM22c - as an exception to RTLM22b: if the computed update contains no changed keys (it is + // empty) no map key actually changed, so instead of returning an update return a LiveMapUpdate + // object with noop set to true (RTLO4b4b), as in RTLM16b. This exception must not be applied when + // the diff is computed for a tombstone per RTLO4e5; LiveObject.tombstone re-synthesizes a + // non-noop update via _createNoChangeUpdate() so the RTLO4b4c3c listener teardown still fires. + if (Object.keys(update.update).length === 0) { + return { noop: true }; + } + return update; } + protected _createNoChangeUpdate(): LiveMapUpdate { + // RTLO4e5 tombstone carve-out (RTLM22c) - an empty no-change update for a map with no + // non-tombstoned entries + return { update: {}, _type: 'LiveMapUpdate' }; + } + protected _mergeInitialDataFromCreateOperation( objectOperation: ObjectOperation, msg: ObjectMessage, diff --git a/src/plugins/liveobjects/liveobject.ts b/src/plugins/liveobjects/liveobject.ts index 459d5f80cc..e21467f6db 100644 --- a/src/plugins/liveobjects/liveobject.ts +++ b/src/plugins/liveobjects/liveobject.ts @@ -134,11 +134,18 @@ export abstract class LiveObject< 'LiveObject.tombstone()', `objectId=${this.getObjectId()}`, ); // RTLO4e3 - const update = this.clearData(); // RTLO4e4 - update.objectMessage = objectMessage; - update.tombstone = true; - - return update; + // RTLO4e5 - compute the diff between the pre-clear data and the zero value. Per the RTLC14c / + // RTLM22c tombstone carve-out, that noop exception "must not be applied when the diff is + // computed for a tombstone": tombstoning an already-empty object yields a noop diff, but the + // resulting tombstone update (RTLO4b4e) must still be delivered so it drives the RTLO4b4c3c + // listener teardown. So when the diff collapses to a noop, synthesize the typed no-change + // update instead, leaving a real (non-noop) update to stamp. + const diff = this.clearData(); // RTLO4e4 + const update: TUpdate = this._isNoopUpdate(diff) ? this._createNoChangeUpdate() : diff; + update.objectMessage = objectMessage; // RTLO4e7 + update.tombstone = true; // RTLO4e6 + + return update; // RTLO4e8 } /** @@ -158,7 +165,7 @@ export abstract class LiveObject< /** * @internal */ - clearData(): TUpdate { + clearData(): TUpdate | LiveObjectUpdateNoop { const previousDataRef = this._dataRef; this._dataRef = this._getZeroValueData(); return this._updateFromDataDiff(previousDataRef, this._dataRef); @@ -350,7 +357,7 @@ export abstract class LiveObject< } } - private _isNoopUpdate(update: TUpdate | LiveObjectUpdateNoop): update is LiveObjectUpdateNoop { + protected _isNoopUpdate(update: TUpdate | LiveObjectUpdateNoop): update is LiveObjectUpdateNoop { return (update as LiveObjectUpdateNoop).noop === true; } @@ -383,8 +390,16 @@ export abstract class LiveObject< protected abstract _getZeroValueData(): TData; /** * Calculate the update object based on the current LiveObject data and incoming new data. + * + * Returns a noop update when the data is unchanged (RTLC14c / RTLM22c). + */ + protected abstract _updateFromDataDiff(prevDataRef: TData, newDataRef: TData): TUpdate | LiveObjectUpdateNoop; + /** + * Returns a typed update that represents "no change" (e.g. a counter delta of 0, or an empty + * map key-diff), used by {@link LiveObject.tombstone} to synthesize a deliverable tombstone + * update when the tombstone diff itself collapsed to a noop per the RTLC14c / RTLM22c carve-out. */ - protected abstract _updateFromDataDiff(prevDataRef: TData, newDataRef: TData): TUpdate; + protected abstract _createNoChangeUpdate(): TUpdate; /** * Merges the initial data from the create operation into the LiveObject. * diff --git a/src/plugins/liveobjects/realtimeobject.ts b/src/plugins/liveobjects/realtimeobject.ts index 9da40826da..b27d78cd96 100644 --- a/src/plugins/liveobjects/realtimeobject.ts +++ b/src/plugins/liveobjects/realtimeobject.ts @@ -396,6 +396,12 @@ export class RealtimeObject { ); } + // RTO20d4 - if the synthetic messages list is empty (e.g. every serial was null and skipped per + // RTO20d1) there is nothing to apply locally, so complete without performing the RTO20e sync wait. + if (syntheticMessages.length === 0) { + return; + } + // RTO20e - Wait for sync to complete if not synced if (this._state !== ObjectsState.synced) { this._client.Logger.logAction( diff --git a/test/uts/objects/unit/live_counter.test.ts b/test/uts/objects/unit/live_counter.test.ts index 3842f57102..8faae0f835 100644 --- a/test/uts/objects/unit/live_counter.test.ts +++ b/test/uts/objects/unit/live_counter.test.ts @@ -511,6 +511,43 @@ describe('uts/objects/unit/live_counter', function () { expect(update.objectMessage).to.equal(msg); }); + // UTS: objects/unit/RTLO5/tombstone-zero-value-counter-emits-update-0 + // Complements object-delete-tombstones-0 (which tombstones a populated counter). Here the + // counter data is already 0, so the tombstone diff (previousData 0, newData 0) is a zero delta. + // Per the RTLC14c tombstone carve-out (RTLO4e5) this update must NOT be marked as a no-op — it + // must still be delivered so the RTLO4b4c3c listener teardown runs. + it('RTLO5 - OBJECT_DELETE on an already-zero counter emits a non-noop tombstone update', async function () { + const { channel, client } = await setupSyncedChannel('test-RTLO5-zero'); + + const counter = createZeroCounter(channel, 'counter:abc@1000'); + const capture = captureNotifyUpdated(counter); + (counter as any)._dataRef.data = 0; + (counter as any)._siteTimeserials = { site1: '00' }; + + const msg = makeObjectMessage(client, { + serial: '01', + siteCode: 'site1', + serialTimestamp: 1700000000000, + operation: { + action: OBJ_OP.OBJECT_DELETE, + objectId: 'counter:abc@1000', + objectDelete: {}, + }, + }); + + const result = counter.applyOperation(msg.operation!, msg, ObjectsOperationSource.channel); + + expect(counter.isTombstoned()).to.equal(true); + expect((counter as any)._dataRef.data).to.equal(0); + expect(result).to.equal(true); + const update = capture.getUpdate(); + // RTLC14c carve-out: the zero-delta tombstone update is NOT a no-op + expect((update as any).noop).to.not.equal(true); + expect(update.tombstone).to.equal(true); + expect(update.update.amount).to.equal(0); + expect(update.objectMessage).to.equal(msg); + }); + // ========================================================================= // RTLC7e - Operations on tombstoned counter are rejected // ========================================================================= @@ -798,6 +835,29 @@ describe('uts/objects/unit/live_counter', function () { expect((update as any).objectMessage).to.equal(stateMsg); }); + // UTS: objects/unit/RTLC14c/zero-delta-diff-is-noop-0 + it('RTLC14c - Zero-delta diff is a no-op', async function () { + const { channel, client } = await setupSyncedChannel('test-RTLC14c'); + + const counter = createZeroCounter(channel, 'counter:abc@1000'); + (counter as any)._dataRef.data = 100; + + const stateMsg = makeObjectMessage(client, { + object: { + objectId: 'counter:abc@1000', + siteTimeserials: { site1: '01' }, + tombstone: false, + counter: { count: 100 }, + }, + }); + + const update = counter.overrideWithObjectState(stateMsg); + + // RTLC14c - the computed delta is 0, so the diff collapses to a no-op update + expect((update as any).noop).to.equal(true); + expect((counter as any)._dataRef.data).to.equal(100); + }); + // ========================================================================= // RTLC8, RTLC16 - COUNTER_CREATE then COUNTER_INC accumulates // ========================================================================= diff --git a/test/uts/objects/unit/live_map.test.ts b/test/uts/objects/unit/live_map.test.ts index 41fc49329e..4ae35cbeb1 100644 --- a/test/uts/objects/unit/live_map.test.ts +++ b/test/uts/objects/unit/live_map.test.ts @@ -753,6 +753,56 @@ describe('uts/objects/unit/live_map', function () { expect(update.objectMessage).to.equal(msg); }); + // UTS: objects/unit/RTLO5/tombstone-empty-map-emits-update-0 + // Complements object-delete-tombstones-map-0 (which tombstones a map with live entries). Here + // every entry is already tombstoned, so the map has no non-tombstoned entries and the tombstone + // diff (per RTLM22b, which considers only non-tombstoned entries) contains no changed keys. Per + // the RTLM22c tombstone carve-out (RTLO4e5) this empty update must NOT be marked as a no-op — it + // must still be delivered so the RTLO4b4c3c listener teardown runs. Uses a non-root map: an + // OBJECT_DELETE targeting root is rejected per RTLO4e10. + it('RTLO5 - OBJECT_DELETE on an all-tombstoned map emits a non-noop tombstone update', async function () { + const { channel, client } = await setupSyncedChannel('test-RTLO5-empty'); + + const map = createZeroMap(channel, 'map:test@1000'); + const capture = captureNotifyUpdated(map); + getDataMap(map).set('name', { + data: { string: 'Alice' }, + timeserial: '01', + tombstone: true, + tombstonedAt: 1600000000000, + }); + getDataMap(map).set('age', { + data: { number: 30 }, + timeserial: '01', + tombstone: true, + tombstonedAt: 1600000000000, + }); + (map as any)._siteTimeserials = { site1: '00' }; + + const msg = makeObjectMessage(client, { + serial: '01', + siteCode: 'site1', + serialTimestamp: 1700000000000, + operation: { + action: OBJ_OP.OBJECT_DELETE, + objectId: 'map:test@1000', + objectDelete: {}, + }, + }); + + const result = map.applyOperation(msg.operation!, msg, ObjectsOperationSource.channel); + + expect(map.isTombstoned()).to.equal(true); + expect(getDataMap(map).size).to.equal(0); // data cleared + expect(result).to.equal(true); + const update = capture.getUpdate(); + // RTLM22c carve-out: the empty tombstone update is NOT a no-op + expect((update as any).noop).to.not.equal(true); + expect(update.tombstone).to.equal(true); + expect(update.update).to.deep.equal({}); + expect(update.objectMessage).to.equal(msg); + }); + // ===================================================================== // RTLO4e10 - OBJECT_DELETE targeting root is rejected // ===================================================================== @@ -1036,6 +1086,41 @@ describe('uts/objects/unit/live_map', function () { expect(diff).to.not.have.property('now_dead'); }); + // UTS: objects/unit/RTLM22c/empty-diff-is-noop-0 + it('RTLM22c - empty diff is a no-op', async function () { + const { channel, client } = await setupSyncedChannel('test-RTLM22c'); + + const map = createZeroMap(channel, 'root'); + getDataMap(map).set('name', { + data: { string: 'alice' }, + timeserial: '01', + tombstone: false, + tombstonedAt: undefined, + }); + + // The non-tombstoned entries before and after are identical under the RTLM22b + // comparison rules (same key, same data; only timeserial differs, which is not compared). + const stateMsg = makeObjectMessage(client, { + object: { + objectId: 'root', + siteTimeserials: { site1: '02' }, + tombstone: false, + map: { + semantics: MAP_SEMANTICS_LWW, + entries: { + name: { data: { string: 'alice' }, timeserial: '02' }, + }, + }, + }, + }); + + const update = map.overrideWithObjectState(stateMsg); + + // RTLM22c - the computed update contains no changed keys, so the diff collapses to a no-op + expect((update as any).noop).to.equal(true); + expect(getDataMap(map).get('name')!.data).to.deep.equal({ string: 'alice' }); + }); + // ===================================================================== // RTLM15d4 - Unsupported action is discarded // ===================================================================== diff --git a/test/uts/objects/unit/live_object_subscribe.test.ts b/test/uts/objects/unit/live_object_subscribe.test.ts index d82b4410bc..5da5dc1db4 100644 --- a/test/uts/objects/unit/live_object_subscribe.test.ts +++ b/test/uts/objects/unit/live_object_subscribe.test.ts @@ -200,6 +200,66 @@ describe('uts/objects/unit/live_object_subscribe', function () { expect(updatesB).to.have.length(1); }); + // UTS: objects/unit/RTLO4b4c3c/tombstone-zero-value-counter-tears-down-0 + // Complements tombstone-deregisters-listeners-0 (which tombstones a populated counter). Here the + // counter (100 in the standard pool) is first driven down to 0, so tombstoning it produces a + // zero-delta diff. Per the RTLC14c tombstone carve-out this update is NOT a no-op (contrast + // RTLO4b4c1 noop-no-trigger, where a genuine noop does not fire the listener at all): the + // listeners still fire with the tombstone update and are then deregistered per RTLO4b4c3c. + it('RTLO4b4c3c - tombstoning an already-zero counter still delivers the update and deregisters listeners', async function () { + const { root, mockWs } = await setupSyncedChannel('test-RTLO4b4c3c-zero'); + + // Drive the counter down to 0 BEFORE registering the listeners under test, so they observe + // only the tombstone. flushAsync is the quiescence barrier that the increment has been applied + // before we subscribe, so the "-100" update is not seen by them. + mockWs.active_connection!.send_to_client( + buildObjectMessage('test-RTLO4b4c3c-zero', [buildCounterInc('counter:score@1000', -100, '40', 'remote')]), + ); + await flushAsync(); + expect(root.get('score').value()).to.equal(0); + + const updatesA: any[] = []; + const updatesB: any[] = []; + const instance = root.get('score').instance()!; + instance.subscribe((event: any) => updatesA.push(event)); + instance.subscribe((event: any) => updatesB.push(event)); + + // OBJECT_DELETE tombstones the already-zero counter (zero-delta diff, RTLC14c -> NOT a no-op) + mockWs.active_connection!.send_to_client( + buildObjectMessage('test-RTLO4b4c3c-zero', [buildObjectDelete('counter:score@1000', '50', 'remote')]), + ); + await flushAsync(); + + // Both listeners received the tombstone update even though the counter data did not change (0 -> 0) + expect(updatesA).to.have.length(1); + expect(updatesA[0].message.operation.action).to.equal('object.delete'); + expect(updatesB).to.have.length(1); + expect(updatesB[0].message.operation.action).to.equal('object.delete'); + + // Prove deregistration. A tombstoned object ignores further ops (RTLC7e), so neither the + // deregistered listeners nor a fresh listener on counter:score@1000 could ever fire — use a + // SEPARATE live object (map:profile@1000) as the quiescence barrier. Messages are processed in + // order, so once the control fires, the follow-up "51" has also been processed. + const control: any[] = []; + const controlInstance = root.get('profile').instance()!; + controlInstance.subscribe((event: any) => control.push(event)); + mockWs.active_connection!.send_to_client( + buildObjectMessage('test-RTLO4b4c3c-zero', [buildCounterInc('counter:score@1000', 3, '51', 'remote')]), + ); + mockWs.active_connection!.send_to_client( + buildObjectMessage('test-RTLO4b4c3c-zero', [ + buildMapSet('map:profile@1000', 'quiescence_probe', { string: 'x' }, '52', 'remote'), + ]), + ); + await flushAsync(); + + // Control delivered, so any still-registered original listener would also have run: the + // tombstone deregistered them per RTLO4b4c3c. + expect(control.length).to.be.greaterThanOrEqual(1); + expect(updatesA).to.have.length(1); + expect(updatesB).to.have.length(1); + }); + // UTS: objects/unit/RTLO4b4d/update-has-object-message-0 it('RTLO4b4d - InstanceSubscriptionEvent.message is populated from source ObjectMessage', async function () { const { root, mockWs } = await setupSyncedChannel('test-RTLO4b4d'); diff --git a/test/uts/objects/unit/objects_pool.test.ts b/test/uts/objects/unit/objects_pool.test.ts index c1cc5aae5b..f83dcd9075 100644 --- a/test/uts/objects/unit/objects_pool.test.ts +++ b/test/uts/objects/unit/objects_pool.test.ts @@ -253,6 +253,69 @@ describe('uts/objects/unit/objects_pool', function () { expect(updates[0].message).to.be.undefined; }); + // UTS: objects/unit/RTO4b2a/reset-of-empty-root-emits-no-update-0 + // Complements attached-no-objects-synced-0 (which resets a populated root and emits a `removed` + // update). Here the root InternalLiveMap is already empty, so the RTO4b2 reset removes no keys: + // the resulting LiveMapUpdate has no changed keys and, per RTLM22c/RTLO4b4b, is a no-op that must + // not be delivered to root subscribers. The liveness control below (populated root) proves the + // subscription wiring is live, so the zero count reflects the empty-root collapse. + it('RTO4b2a - reset of an already-empty root emits no update', async function () { + const { channel, root, mockWs, client } = await setupSyncedChannel('test-RTO4b2a'); + const rto = getRealtimeObject(channel); + const pool = rto.getPool(); + + // White-box: make the root already empty and add a non-root object to the pool. + const rootMap = pool.get('root'); + (rootMap as any)._dataRef.data.clear(); + pool.createZeroValueObjectIfNotExists('counter:abc@1000'); + expect(root.size()).to.equal(0); + + // Subscribe to root; capture the internal update passed to root's notifyUpdated too. + const updates: any[] = []; + root.subscribe((event: any) => updates.push(event)); + const capture = captureNotifyUpdated(rootMap); + + // ATTACHED without HAS_OBJECTS triggers the RTO4b reset path. + mockWs.active_connection!.send_to_client({ + action: PM_ACTION.ATTACHED, + channel: 'test-RTO4b2a', + flags: 0, + }); + await flushAsync(); + capture.restore(); + + expect(rto._state).to.equal('synced'); + // RTO4b1: non-root objects are still removed + expect(pool.get('counter:abc@1000')).to.be.undefined; + expect(pool.get('root')).to.exist; + expect(root.size()).to.equal(0); + // RTO4b2a: no keys were removed, so the empty update collapses to a no-op and is not delivered + expect(updates.length).to.equal(0); + // the internal diff itself is a no-op (RTLM22c), so notifyUpdated suppressed it + expect((capture.getUpdate() as any).noop).to.equal(true); + + // Liveness control: a reset that DOES remove a key still emits, so the zero count above + // reflects the empty-root collapse and not a dead subscription. Mirrors RTO4b. Uses a second + // channel on the same client/connection (the mock WebSocket is a singleton), whose root is + // populated from the standard sync pool. + const channel2 = client.channels.get('test-RTO4b2a-control', { modes: ['OBJECT_SUBSCRIBE', 'OBJECT_PUBLISH'] }); + const root2 = await channel2.object.get(); + const rootMap2 = getRealtimeObject(channel2).getPool().get('root'); + const control: any[] = []; + root2.subscribe((event: any) => control.push(event)); + const capture2 = captureNotifyUpdated(rootMap2); + mockWs.active_connection!.send_to_client({ + action: PM_ACTION.ATTACHED, + channel: 'test-RTO4b2a-control', + flags: 0, + }); + await flushAsync(); + capture2.restore(); + + expect(control.length).to.be.greaterThanOrEqual(1); + expect(capture2.getUpdate().update.name).to.equal('removed'); + }); + // UTS: objects/unit/RTO5/sync-complete-sequence-0 it('RTO5 - OBJECT_SYNC complete sequence populates pool', async function () { const { channel, mockWs } = await setupManualChannel('test-RTO5', { diff --git a/test/uts/objects/unit/realtime_object.test.ts b/test/uts/objects/unit/realtime_object.test.ts index eef5cf40d6..dd97858605 100644 --- a/test/uts/objects/unit/realtime_object.test.ts +++ b/test/uts/objects/unit/realtime_object.test.ts @@ -534,6 +534,71 @@ describe('uts/objects/unit/realtime_object', function () { expect(root.get('score').value()).to.equal(100); }); + // UTS: objects/unit/RTO20d4/empty-synthetic-list-skips-sync-wait-0 + it('RTO20d4 - empty synthetic list skips the RTO20e sync wait', async function () { + const mockWs = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mockWs.active_connection = conn; + conn.respond_with_connected({ + action: PM_ACTION.CONNECTED, + connectionId: 'conn-1', + connectionDetails: { + connectionKey: 'key-1', + siteCode: 'test-site', + objectsGCGracePeriod: 86400000, + }, + }); + }, + onMessageFromClient: (msg: any) => { + if (msg.action === PM_ACTION.ATTACH) { + mockWs.active_connection!.send_to_client({ + action: PM_ACTION.ATTACHED, + channel: msg.channel, + channelSerial: 'sync1:', + flags: HAS_OBJECTS, + }); + mockWs.active_connection!.send_to_client( + buildObjectSyncMessage('test-RTO20d4', 'sync1:', STANDARD_POOL_OBJECTS), + ); + } else if (msg.action === PM_ACTION.OBJECT) { + // All-null serial list -> every synthetic ObjectMessage is skipped per RTO20d1 + mockWs.active_connection!.send_to_client(buildAckMessage(msg.msgSerial, [null as any])); + } + }, + }); + installMockWebSocket(mockWs.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + plugins: { LiveObjects: LiveObjectsPlugin }, + }); + trackClient(client); + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTO20d4', { modes: ['OBJECT_SUBSCRIBE', 'OBJECT_PUBLISH'] }); + const root = await channel.object.get(); + + // Move the objects sync state back to SYNCING so a normal publishAndApply would park in + // the RTO20e wait for SYNCED. No sync-completing message is ever sent, so if the RTO20e wait + // were performed this future would never resolve. + mockWs.active_connection!.send_to_client({ + action: PM_ACTION.ATTACHED, + channel: 'test-RTO20d4', + channelSerial: 'sync2:cursor', + flags: HAS_OBJECTS, + }); + + // RTO20d4 - the all-null ACK makes the synthetic list empty, so publishAndApply completes + // without the RTO20e wait. Resolution despite the channel never reaching SYNCED proves it. + await root.get('score').increment(10); + + // Nothing applied locally, so the value is unchanged. + expect(root.get('score').value()).to.equal(100); + }); + // UTS: objects/unit/RTO20e/waits-for-synced-0 it('RTO20e - publishAndApply waits for SYNCED during SYNCING', async function () { const { root, mockWs } = await setupSyncedChannel('test-RTO20e');