Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion src/plugins/liveobjects/livecounter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,12 @@ export class LiveCounter extends LiveObject<LiveCounterData, LiveCounterUpdate>

// 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;
Expand All @@ -253,11 +259,27 @@ export class LiveCounter extends LiveObject<LiveCounterData, LiveCounterUpdate>
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<ObjectData>,
msg: ObjectMessage,
Expand Down
28 changes: 26 additions & 2 deletions src/plugins/liveobjects/livemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,12 @@ export class LiveMap<T extends Record<string, Value> = Record<string, Value>>

// 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;
Expand All @@ -488,7 +494,7 @@ export class LiveMap<T extends Record<string, Value> = Record<string, Value>>
*
* @internal
*/
clearData(): LiveMapUpdate<T> {
clearData(): LiveMapUpdate<T> | 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) {
Expand Down Expand Up @@ -600,7 +606,10 @@ export class LiveMap<T extends Record<string, Value> = Record<string, Value>>
return { data: new Map<string, LiveMapEntry>() };
}

protected _updateFromDataDiff(prevDataRef: LiveMapData, newDataRef: LiveMapData): LiveMapUpdate<T> {
protected _updateFromDataDiff(
prevDataRef: LiveMapData,
newDataRef: LiveMapData,
): LiveMapUpdate<T> | LiveObjectUpdateNoop {
const update: LiveMapUpdate<T> = { update: {}, _type: 'LiveMapUpdate' };

for (const [key, currentEntry] of prevDataRef.data.entries()) {
Expand Down Expand Up @@ -653,9 +662,24 @@ export class LiveMap<T extends Record<string, Value> = Record<string, Value>>
}
}

// 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<T> {
// 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<ObjectData>,
msg: ObjectMessage,
Expand Down
31 changes: 23 additions & 8 deletions src/plugins/liveobjects/liveobject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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.
*
Expand Down
6 changes: 6 additions & 0 deletions src/plugins/liveobjects/realtimeobject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,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(
Expand Down
60 changes: 60 additions & 0 deletions test/uts/objects/unit/live_counter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// =========================================================================
Expand Down Expand Up @@ -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
// =========================================================================
Expand Down
85 changes: 85 additions & 0 deletions test/uts/objects/unit/live_map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// =====================================================================
Expand Down Expand Up @@ -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
// =====================================================================
Expand Down
Loading
Loading