fix(websocket): dedup AAPS profile dbAdd by startDate; warn on insert errors

AAPS V1 NSClient sync only ever calls nsAdd("profile", ...) — there is no
nsUpdate path for profiles (DataSyncSelectorV1.processChangedProfileStore).
Every profile edit reaches the server's websocket dbAdd handler.

Previously the dbAdd handler had no dedup branch for the 'profile' collection
and fell through to the generic else, which called insertOne() unconditionally
and then silently swallowed any insertion error via console.log + return [].
Result: each AAPS edit either created a duplicate profile document or failed
silently if the source JSONObject still carried an _id (E11000 dup key), so
users perceived their profile updates as not taking effect.

Changes:
- websocket.js: add a profile dedup branch — match on NSCLIENT_ID if present,
  otherwise on startDate, and replaceOne in place rather than insertOne.
  Returns the existing _id so the AAPS ack worker sees a stable identifier.
- websocket.js: upgrade the silent 'insertion error' console.log to
  console.warn for both the profile branch and the generic fallback so
  MongoDB write failures are visible in server logs.
- profile.js: add _id as a secondary sort key in last() so duplicate
  startDate values resolve deterministically (newest insert wins) for any
  legacy duplicates already present.
- tests/websocket.shape-handling.test.js: regression coverage for the
  AAPS-shaped profile flow — first insert, repeated dbAdd with same
  startDate (expect replace, not duplicate), and distinct startDate
  (expect insert + last() returns newest).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Ben West
2026-04-20 11:52:12 -07:00
co-authored by Copilot
parent b8d5c02d52
commit 85f7e6ac7c
3 changed files with 187 additions and 2 deletions
+1 -1
View File
@@ -119,7 +119,7 @@ function storage (collection, ctx) {
function last (fn) {
return runWithCallback(function () {
return api().find().sort({startDate: -1}).limit(1).toArray();
return api().find().sort({startDate: -1, _id: -1}).limit(1).toArray();
}, fn);
}
+50 -1
View File
@@ -511,6 +511,55 @@ function init (env, ctx, server) {
}
throw err;
}
// profile deduping (AAPS V1 sync only sends dbAdd, never dbUpdate, for profile)
} else if (data.collection === 'profile') {
var profileQuery = null;
if (data.data.NSCLIENT_ID) {
profileQuery = { NSCLIENT_ID: data.data.NSCLIENT_ID };
} else if (data.data.startDate) {
profileQuery = { startDate: data.data.startDate };
}
if (profileQuery) {
try {
var existingProfile = await mongoCollection.findOne(profileQuery);
if (existingProfile) {
console.log(LOG_DEDUP + 'Profile match on ' + Object.keys(profileQuery).join(',') + '; replacing existing _id=' + existingProfile._id);
var replacementDoc = Object.assign({}, data.data);
replacementDoc._id = existingProfile._id;
await mongoCollection.replaceOne({ _id: existingProfile._id }, replacementDoc);
ctx.bus.emit('data-update', {
type: 'profile'
, op: 'update'
, changes: ctx.ddata.processRawDataForRuntime([replacementDoc])
});
ctx.bus.emit('data-received');
return [replacementDoc];
}
} catch (err) {
console.warn('profile dedup lookup error: ', err && err.message ? err.message : err);
return [];
}
}
try {
var profileInsertResult = await mongoCollection.insertOne(data.data);
var profileDoc = data.data;
profileDoc._id = profileInsertResult.insertedId;
ctx.bus.emit('data-update', {
type: 'profile'
, op: 'update'
, changes: ctx.ddata.processRawDataForRuntime([profileDoc])
});
ctx.bus.emit('data-received');
return [profileDoc];
} catch (err) {
if (err != null && err.message) {
console.warn('profile insertion error: ', err.message);
return [];
}
throw err;
}
} else {
try {
var genericInsertResult = await mongoCollection.insertOne(data.data);
@@ -525,7 +574,7 @@ function init (env, ctx, server) {
return [genericDoc];
} catch (err) {
if (err != null && err.message) {
console.log(data.collection + ' insertion error: ', err.message);
console.warn(data.collection + ' insertion error: ', err.message);
return [];
}
throw err;
+136
View File
@@ -496,6 +496,142 @@ describe('WebSocket Shape Handling - dbAdd Single vs Array Input', function () {
});
});
describe('dbAdd profile collection (AAPS V1 sync)', function () {
function profileCollection() {
return self.ctx.store.collection(self.env.profile_collection);
}
beforeEach(async function () {
await profileCollection().deleteMany({ defaultProfile: 'aaps-test' });
});
function aapsProfile(startDateMs, overrides) {
var iso = new Date(startDateMs).toISOString();
return Object.assign({
defaultProfile: 'aaps-test',
date: startDateMs,
created_at: iso,
startDate: iso,
units: 'mg/dl',
store: {
'aaps-test': {
dia: 5,
carbratio: [{ time: '00:00', value: 10 }],
sens: [{ time: '00:00', value: 50 }],
basal: [{ time: '00:00', value: 0.5 }],
target_low: [{ time: '00:00', value: 100 }],
target_high: [{ time: '00:00', value: 120 }],
timezone: 'UTC'
}
}
}, overrides || {});
}
it('first AAPS-shaped profile dbAdd inserts a new document', function (done) {
connectAndAuthorize(function (err, socket) {
if (err) return done(err);
var profile = aapsProfile(Date.now());
socket.emit('dbAdd', { collection: 'profile', data: profile }, function (result) {
should.exist(result);
result.should.be.instanceof(Array);
result.length.should.equal(1);
should.exist(result[0]._id);
waitForConditionWithWarning({
condition: function (cb) {
profileCollection().find({ defaultProfile: 'aaps-test' }).toArray()
.then(function (docs) { cb(null, docs); }).catch(cb);
},
assertion: function (docs) {
docs.length.should.equal(1);
docs[0].startDate.should.equal(profile.startDate);
},
done: done,
operationName: 'verify first AAPS profile insert'
});
});
});
});
it('repeated AAPS profile dbAdd with same startDate REPLACES instead of duplicating', function (done) {
connectAndAuthorize(function (err, socket) {
if (err) return done(err);
var ts = Date.now();
var first = aapsProfile(ts);
// second send: same startDate (e.g. user re-saves quickly), edited carb ratio
var second = aapsProfile(ts);
second.store['aaps-test'].carbratio[0].value = 12;
socket.emit('dbAdd', { collection: 'profile', data: first }, function (firstResult) {
should.exist(firstResult);
firstResult.length.should.equal(1);
var firstId = firstResult[0]._id;
socket.emit('dbAdd', { collection: 'profile', data: second }, function (secondResult) {
should.exist(secondResult);
secondResult.length.should.equal(1);
// The dedup branch returns the EXISTING _id so AAPS sees a stable id
String(secondResult[0]._id).should.equal(String(firstId));
waitForConditionWithWarning({
condition: function (cb) {
profileCollection().find({ defaultProfile: 'aaps-test' }).toArray()
.then(function (docs) { cb(null, docs); }).catch(cb);
},
assertion: function (docs) {
docs.length.should.equal(1);
docs[0].store['aaps-test'].carbratio[0].value.should.equal(12);
},
done: done,
operationName: 'verify AAPS profile dedup replaces in place'
});
});
});
});
});
it('AAPS profile dbAdd with different startDate inserts a new document and last() returns newest', function (done) {
connectAndAuthorize(function (err, socket) {
if (err) return done(err);
var older = aapsProfile(Date.now() - 60000);
older.store['aaps-test'].carbratio[0].value = 8;
var newer = aapsProfile(Date.now());
newer.store['aaps-test'].carbratio[0].value = 14;
socket.emit('dbAdd', { collection: 'profile', data: older }, function (r1) {
should.exist(r1);
socket.emit('dbAdd', { collection: 'profile', data: newer }, function (r2) {
should.exist(r2);
waitForConditionWithWarning({
condition: function (cb) {
profileCollection().find({ defaultProfile: 'aaps-test' }).toArray()
.then(function (docs) { cb(null, docs); }).catch(cb);
},
assertion: function (docs) {
docs.length.should.equal(2);
},
done: function (err) {
if (err) return done(err);
self.ctx.profile.last(function (lastErr, lastDocs) {
if (lastErr) return done(lastErr);
lastDocs.length.should.equal(1);
lastDocs[0].store['aaps-test'].carbratio[0].value.should.equal(14);
done();
});
},
operationName: 'verify AAPS profile distinct startDate inserts and last() returns newest'
});
});
});
});
});
});
describe('generic collection raw write watchpoints', function () {
beforeEach(async function () {