Skip to content

Commit 8a763d8

Browse files
Test receiving two create ops for the same object ID
These tests demonstrate the necessity of the createOperationIsMerged flag, which I had previously incorrectly proposed removing — one reason for which was that no tests failed when I tried removing it. See internal conversation in [1]. Tests written by Claude. [1] https://ably-real-time.slack.com/archives/C09SY1AQGK0/p1769459426372989?thread_ts=1769195388.456439&cid=C09SY1AQGK0
1 parent ea73154 commit 8a763d8

1 file changed

Lines changed: 115 additions & 0 deletions

File tree

test/realtime/liveobjects.test.js

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1357,6 +1357,75 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f
13571357
},
13581358
},
13591359

1360+
{
1361+
description: 'only one MAP_CREATE operation is applied for the same object ID',
1362+
action: async (ctx) => {
1363+
const { entryInstance, objectsHelper, channel } = ctx;
1364+
1365+
// It's possible for multiple MAP_CREATE operations, with different serials, to be received
1366+
// for the same object ID. The object ID is derived from the operation's content, so they will
1367+
// have identical content. The client should only merge one of these operations into the object's data.
1368+
1369+
// create new map and set on root
1370+
const mapId = objectsHelper.fakeMapObjectId();
1371+
await objectsHelper.processObjectOperationMessageOnChannel({
1372+
channel,
1373+
serial: lexicoTimeserial('aaa', 0, 0),
1374+
siteCode: 'aaa',
1375+
state: [
1376+
objectsHelper.mapCreateOp({
1377+
objectId: mapId,
1378+
entries: {
1379+
foo: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { string: 'bar' } },
1380+
},
1381+
}),
1382+
],
1383+
});
1384+
await objectsHelper.processObjectOperationMessageOnChannel({
1385+
channel,
1386+
serial: lexicoTimeserial('aaa', 1, 0),
1387+
siteCode: 'aaa',
1388+
state: [objectsHelper.mapSetOp({ objectId: 'root', key: mapId, data: { objectId: mapId } })],
1389+
});
1390+
1391+
expect(entryInstance.get(mapId).size()).to.equal(1, 'Check map has 1 key after first MAP_CREATE');
1392+
expect(entryInstance.get(mapId).get('foo').value()).to.equal(
1393+
'bar',
1394+
'Check map has correct "foo" value after first MAP_CREATE',
1395+
);
1396+
1397+
// send another MAP_CREATE op for the same object ID, from a different site, with a later entry timeserial
1398+
await objectsHelper.processObjectOperationMessageOnChannel({
1399+
channel,
1400+
serial: lexicoTimeserial('ccc', 0, 0),
1401+
siteCode: 'ccc',
1402+
state: [
1403+
objectsHelper.mapCreateOp({
1404+
objectId: mapId,
1405+
entries: {
1406+
foo: { timeserial: lexicoTimeserial('ccc', 0, 0), data: { string: 'bar' } },
1407+
},
1408+
}),
1409+
],
1410+
});
1411+
1412+
// verify the second CREATE was not applied by checking that a MAP_SET with an intermediate
1413+
// timeserial ('bbb') can still be applied. if the second CREATE had been wrongly applied,
1414+
// the entry's timeserial would be 'ccc' and this MAP_SET would be rejected.
1415+
await objectsHelper.processObjectOperationMessageOnChannel({
1416+
channel,
1417+
serial: lexicoTimeserial('bbb', 0, 0),
1418+
siteCode: 'bbb',
1419+
state: [objectsHelper.mapSetOp({ objectId: mapId, key: 'foo', data: { string: 'updated' } })],
1420+
});
1421+
1422+
expect(entryInstance.get(mapId).get('foo').value()).to.equal(
1423+
'updated',
1424+
'Check MAP_SET was applied, proving second MAP_CREATE did not update entry timeserial',
1425+
);
1426+
},
1427+
},
1428+
13601429
{
13611430
allTransportsAndProtocols: true,
13621431
description: 'can apply MAP_SET with primitives object operation messages',
@@ -1849,6 +1918,52 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f
18491918
},
18501919
},
18511920

1921+
{
1922+
description: 'only one COUNTER_CREATE operation is applied for the same object ID',
1923+
action: async (ctx) => {
1924+
const { entryInstance, objectsHelper, channel } = ctx;
1925+
1926+
// It's possible for multiple COUNTER_CREATE operations, with different serials, to be received
1927+
// for the same object ID. The object ID is derived from the operation's content, so they will
1928+
// have identical content. The client should only merge one of these operations into the object's data.
1929+
1930+
// create new counter and set on root
1931+
const counterId = objectsHelper.fakeCounterObjectId();
1932+
await objectsHelper.processObjectOperationMessageOnChannel({
1933+
channel,
1934+
serial: lexicoTimeserial('aaa', 0, 0),
1935+
siteCode: 'aaa',
1936+
state: [objectsHelper.counterCreateOp({ objectId: counterId, count: 10 })],
1937+
});
1938+
await objectsHelper.processObjectOperationMessageOnChannel({
1939+
channel,
1940+
serial: lexicoTimeserial('aaa', 1, 0),
1941+
siteCode: 'aaa',
1942+
state: [objectsHelper.mapSetOp({ objectId: 'root', key: counterId, data: { objectId: counterId } })],
1943+
});
1944+
1945+
expect(entryInstance.get(counterId).value()).to.equal(
1946+
10,
1947+
'Check counter has value 10 after first COUNTER_CREATE',
1948+
);
1949+
1950+
// send another COUNTER_CREATE op for the same object ID, from a different site
1951+
await objectsHelper.processObjectOperationMessageOnChannel({
1952+
channel,
1953+
serial: lexicoTimeserial('bbb', 0, 0),
1954+
siteCode: 'bbb',
1955+
state: [objectsHelper.counterCreateOp({ objectId: counterId, count: 10 })],
1956+
});
1957+
1958+
// verify the second CREATE was not applied - if it had been, the count would have been
1959+
// added again (10 + 10 = 20) due to how COUNTER_CREATE merges into existing state
1960+
expect(entryInstance.get(counterId).value()).to.equal(
1961+
10,
1962+
'Check counter still has value 10 after second COUNTER_CREATE (not applied)',
1963+
);
1964+
},
1965+
},
1966+
18521967
{
18531968
allTransportsAndProtocols: true,
18541969
description: 'can apply COUNTER_INC object operation messages',

0 commit comments

Comments
 (0)