diff --git a/lib/data/entity.js b/lib/data/entity.js
index 3be7d9932..13c0d3f3f 100644
--- a/lib/data/entity.js
+++ b/lib/data/entity.js
@@ -395,6 +395,64 @@ const diffEntityData = (defData) => {
return diffs;
};
+// Copied from frontend though it may not all be necessary
+// Offline branch
+class Branch {
+ // firstUpdate is the first offline update (not create) to be processed from
+ // the branch. entityRoot is the first version of the entity.
+ constructor(firstUpdate, entityRoot) {
+ if (firstUpdate.trunkVersion != null) {
+ // The first version from the branch to be processed (not necessarily the
+ // first in the original branch order)
+ this.first = firstUpdate;
+
+ // How many versions that have been processed are from the branch?
+ this.length = 1;
+
+ // Was this.first processed in branch order, or was it processed before an
+ // earlier change in the branch?
+ const { trunkVersion } = firstUpdate;
+ this.firstInOrder = firstUpdate.branchBaseVersion === trunkVersion;
+
+ /* this.lastContiguousWithTrunk is the version number of the last version
+ from the branch that is contiguous with the trunk version. In other words,
+ it is the version number of the last version where there has been no
+ update from outside the branch between the version and the trunk version.
+ this.lastContiguousWithTrunk is not related to branch order: as long as
+ there hasn't been an update from outside the branch, the branch is
+ contiguous, regardless of the order of the updates within it. */
+ this.lastContiguousWithTrunk = firstUpdate.version === trunkVersion + 1
+ ? firstUpdate.version
+ : 0;
+ } else {
+ // If the entity was both created and updated offline before being sent to
+ // the server, then we treat the creation as part of the same branch as
+ // the update(s). The creation doesn't have a branch ID, but we treat it
+ // as part of the branch anyway.
+ this.first = entityRoot;
+ // If the submission for the entity creation was received late and
+ // processed out of order, then firstUpdate.version === 1. In that case,
+ // we can't reliably determine which entity version corresponds to the
+ // entity creation, so we don't treat the creation as part of the branch.
+ this.length = firstUpdate.version === 1 ? 1 : 2;
+ this.firstInOrder = this.length === 2;
+ this.lastContiguousWithTrunk = firstUpdate.version === 2 ? 2 : 1;
+ }
+
+ this.id = firstUpdate.branchId;
+ // The last version from the branch to be processed
+ this.last = firstUpdate;
+ }
+
+ add(version) {
+ this.length += 1;
+ this.last = version;
+ if (version.baseVersion === this.lastContiguousWithTrunk &&
+ version.version === version.baseVersion + 1)
+ this.lastContiguousWithTrunk = version.version;
+ }
+}
+
// Returns an array of properties which are different between
// `dataReceived` and `otherVersionData`
const getDiffProp = (dataReceived, otherVersionData) =>
@@ -417,6 +475,26 @@ const getWithConflictDetails = (defs, audits, relevantToConflict) => {
const relevantBaseVersions = new Set();
+ // build up branches
+ const branches = new Map();
+ for (const version of defs) {
+ const { branchId } = version;
+ if (branchId != null && version.branchBaseVersion != null) {
+ const existingBranch = branches.get(branchId);
+ if (existingBranch == null) {
+ const newBranch = new Branch(version, defs[0]);
+ branches.set(branchId, newBranch);
+ version.branch = newBranch;
+ // If the entity was created offline, then add the branch to the
+ // entity creation.
+ newBranch.first.branch = newBranch;
+ } else {
+ existingBranch.add(version);
+ version.branch = existingBranch;
+ }
+ }
+ }
+
for (const def of defs) {
const v = mergeLeft(def.forApi(),
@@ -436,7 +514,12 @@ const getWithConflictDetails = (defs, audits, relevantToConflict) => {
v.source = event.source;
if (v.version > 1) { // v.root is false here - can use either
- const conflict = v.version !== (v.baseVersion + 1);
+ let notContiguousWithTrunk = false;
+ if (v.branchId != null) {
+ notContiguousWithTrunk = branches.get(v.branchId).lastContiguousWithTrunk < v.baseVersion;
+ }
+
+ const conflict = v.version !== (v.baseVersion + 1) || notContiguousWithTrunk;
v.baseDiff = getDiffProp(v.dataReceived, { ...defs[v.baseVersion - 1].data, label: defs[v.baseVersion - 1].label });
diff --git a/test/integration/api/offline-entities.js b/test/integration/api/offline-entities.js
index a10a6301d..09d82a604 100644
--- a/test/integration/api/offline-entities.js
+++ b/test/integration/api/offline-entities.js
@@ -1270,4 +1270,90 @@ describe('Offline Entities', () => {
}));
});
});
+
+ describe('conflict cases', () => {
+ it('should mark an update that is not contiguous with its trunk version as a soft conflict', testOfflineEntities(async (service, container) => {
+ const asAlice = await service.login('alice');
+ const branchId = uuid();
+
+ // Update existing entity on server (change age from 22 to 24)
+ await asAlice.patch('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc?baseVersion=1')
+ .send({ data: { age: '24' } })
+ .expect(200);
+
+ // Send update (change status from null to arrived)
+ await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions')
+ .send(testData.instances.offlineEntity.one
+ .replace('branchId=""', `branchId="${branchId}"`)
+ )
+ .set('Content-Type', 'application/xml')
+ .expect(200);
+
+ // Send second update (change age from 22 to 26)
+ await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions')
+ .send(testData.instances.offlineEntity.one
+ .replace('branchId=""', `branchId="${branchId}"`)
+ .replace('one', 'one-update2')
+ .replace('baseVersion="1"', 'baseVersion="2"')
+ .replace('arrived', '26')
+ )
+ .set('Content-Type', 'application/xml')
+ .expect(200);
+
+ await exhaust(container);
+
+ await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/versions')
+ .then(({ body: versions }) => {
+ versions.map(v => v.conflict).should.eql([null, null, 'soft', 'soft']);
+ });
+ }));
+
+ it('should mark an update that is not contiguous with its trunk version as a soft conflict', testOfflineEntities(async (service, container) => {
+ const asAlice = await service.login('alice');
+ const branchId = uuid();
+
+ // Send second update first
+ await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions')
+ .send(testData.instances.offlineEntity.one
+ .replace('branchId=""', `branchId="${branchId}"`)
+ .replace('one', 'one-update2')
+ .replace('baseVersion="1"', 'baseVersion="2"')
+ .replace('arrived', 'checked in')
+ )
+ .set('Content-Type', 'application/xml')
+ .expect(200);
+
+ await exhaust(container);
+ await container.Entities.processBacklog(true);
+
+ // Send first update now (it will be applied right away)
+ await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions')
+ .send(testData.instances.offlineEntity.one
+ .replace('branchId=""', `branchId="${branchId}"`)
+ )
+ .set('Content-Type', 'application/xml')
+ .expect(200);
+
+ await exhaust(container);
+
+ // Send fourth update
+ await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions')
+ .send(testData.instances.offlineEntity.one
+ .replace('branchId=""', `branchId="${branchId}"`)
+ .replace('one', 'one-update4')
+ .replace('baseVersion="1"', 'baseVersion="4"')
+ .replace('arrived', 'departed')
+ )
+ .set('Content-Type', 'application/xml')
+ .expect(200);
+
+ await exhaust(container);
+ await container.Entities.processBacklog(true);
+
+ await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/versions')
+ .then(({ body: versions }) => {
+ versions.map(v => v.conflict).should.eql([null, null, 'hard', 'soft']);
+ });
+ }));
+ });
});