fix(api3): dedup AAPS profile-store edits via REST POST /v3/profile

Profile-store documents are singleton-per-(app, defaultProfile) by design:
each source (e.g. AAPS) has one current profile snapshot at a time. The
prior identifier scheme (uuidv5 of "undefined_<doc.date>") created a new
identifier on every edit because AAPS sends a new `date`
(LocalProfileLastChange) per save, accumulating duplicate profile docs in
MongoDB and causing 'AAPS profile edits not appearing' user reports.

Changes:
- operationTools.calculateIdentifier: special-case profile-store shape
  (has `defaultProfile` + `store`, no `eventType`) -> identifier =
  uuidv5("profilestore_<app>_<defaultProfile>"), so re-sends and edits
  collapse onto the same row.
- update/validate: relax immutability of `date`, `created_at`,
  `startDate` during deduplication when the storage doc is a
  profile-store, since those fields are expected to advance per edit.
- api3.aaps-patterns tests updated to assert post-fix behavior:
  edits return 200 + same identifier + single doc; distinct
  defaultProfile names still produce distinct docs.

This complements the V1 (websocket) profile dedup fix in 85f7e6ac so
both AAPS sync paths now converge on a single profile document per
source.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Ben West
2026-04-20 12:25:00 -07:00
co-authored by Copilot
parent ddabdc6ca7
commit 3b786ab3c0
3 changed files with 60 additions and 8 deletions
+12
View File
@@ -21,6 +21,15 @@ function validate (opCtx, doc, storageDoc, options) {
const immutable = ['identifier', 'date', 'utcOffset', 'eventType', 'device', 'app',
'srvCreated', 'subject', 'srvModified', 'modifiedBy', 'isValid'];
// Profile-store documents are dedup'd by (app, defaultProfile) — the whole
// point of that dedup is that AAPS-style edits carry a NEW `date`
// (LocalProfileLastChange) but should still update the existing row.
// Relax `date`/`created_at`/`startDate` immutability for profile-store
// deduplication so the latest edit overwrites the previous snapshot.
const isProfileStoreDedup = isDeduplication
&& storageDoc && storageDoc.defaultProfile && storageDoc.store;
const profileStoreMutable = new Set(['date', 'created_at', 'startDate']);
if (storageDoc.isReadOnly === true || storageDoc.readOnly === true || storageDoc.readonly === true) {
return opTools.sendJSONStatus(res, apiConst.HTTP.UNPROCESSABLE_ENTITY,
apiConst.MSG.HTTP_422_READONLY_MODIFICATION);
@@ -36,6 +45,9 @@ function validate (opCtx, doc, storageDoc, options) {
if (storageDoc.isValid === false)
continue;
if (isProfileStoreDedup && profileStoreMutable.has(field))
continue;
if (typeof(doc[field]) !== 'undefined' && doc[field] !== storageDoc[field]) {
return opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST,
apiConst.MSG.HTTP_400_IMMUTABLE_FIELD.replace('{0}', field));
+11
View File
@@ -98,6 +98,17 @@ function calculateIdentifier (doc) {
if (!doc)
return undefined;
// Profile-store documents are a singleton-per-(app, defaultProfile) by design:
// each source (e.g. AAPS) has one "current profile store" at a time. Without
// this special case, every edit produces a new identifier (because `date`
// changes per edit and `device`/`eventType` are absent) and accumulates
// duplicate profile documents. Dedup by (app, defaultProfile) so that
// re-sends and edits collapse onto the same row.
if (doc.defaultProfile && doc.store && !doc.eventType) {
const profileKey = 'profilestore_' + (doc.app || 'unknown') + '_' + doc.defaultProfile;
return uuid.v5(profileKey, uuidNamespace);
}
let key = doc.device + '_' + doc.date;
if (doc.eventType) {
key += '_' + doc.eventType;
+37 -8
View File
@@ -652,8 +652,11 @@ describe('API3 AAPS Patterns - Deduplication and Real-world Scenarios', function
docs.length.should.equal(1);
});
it('AAPS edit (new date from LocalProfileLastChange) creates a SECOND doc, not an update', async () => {
// Simulates: user edits profile in AAPS twice -> two distinct LocalProfileLastChange values
it('AAPS edit (new date from LocalProfileLastChange) DEDUPS onto existing profile (post-fix)', async () => {
// Simulates: user edits profile in AAPS twice -> two distinct LocalProfileLastChange values.
// Pre-fix: V3 inserted a new doc per edit because identifier = uuidv5("undefined_<date>").
// Post-fix: profile-store identifier is uuidv5("profilestore_<app>_<defaultProfile>"),
// so edits with the same (app, defaultProfile) replace the existing doc.
const t1 = Date.now() - 60000;
const first = aapsV3Profile(t1);
first.store['aaps-v3-test'].carbratio[0].value = 8;
@@ -664,15 +667,16 @@ describe('API3 AAPS Patterns - Deduplication and Real-world Scenarios', function
res1.status.should.equal(201);
self.cache.clear();
const res2 = await self.instance.post(url, self.jwt.create).send(second);
// V3 inserts a NEW doc because identifier (uuidv5 of "undefined_<date>") differs
res2.status.should.equal(201);
res2.body.identifier.should.not.equal(res1.body.identifier);
const res2 = await self.instance.post(url, self.jwt.update).send(second);
// Post-fix: edit dedups in place -> 200, same identifier
res2.status.should.equal(200);
res2.body.identifier.should.equal(res1.body.identifier);
const docs = await profileCollection().find({ defaultProfile: 'aaps-v3-test' }).toArray();
docs.length.should.equal(2);
docs.length.should.equal(1);
docs[0].store['aaps-v3-test'].carbratio[0].value.should.equal(14);
// Verify ctx.profile.last() returns the newer profile (post-fix sort: startDate desc, _id desc)
// Verify ctx.profile.last() returns the updated profile (post-fix sort: startDate desc, _id desc)
await new Promise((resolve, reject) => {
self.instance.ctx.profile.last((err, lastDocs) => {
if (err) return reject(err);
@@ -684,5 +688,30 @@ describe('API3 AAPS Patterns - Deduplication and Real-world Scenarios', function
});
});
});
it('different defaultProfile names produce distinct V3 profile docs', async () => {
// Two different profile names should NOT collide under the new dedup key.
const a = aapsV3Profile(Date.now());
a.defaultProfile = 'aaps-v3-test';
a.store = { 'aaps-v3-test': a.store['aaps-v3-test'] };
const b = aapsV3Profile(Date.now() + 1);
b.defaultProfile = 'aaps-v3-test-other';
b.store = { 'aaps-v3-test-other': a.store['aaps-v3-test'] };
const resA = await self.instance.post(url, self.jwt.create).send(a);
resA.status.should.equal(201);
self.cache.clear();
const resB = await self.instance.post(url, self.jwt.create).send(b);
resB.status.should.equal(201);
resB.body.identifier.should.not.equal(resA.body.identifier);
const docsA = await profileCollection().find({ defaultProfile: 'aaps-v3-test' }).toArray();
const docsB = await profileCollection().find({ defaultProfile: 'aaps-v3-test-other' }).toArray();
docsA.length.should.equal(1);
docsB.length.should.equal(1);
// cleanup the extra one
await profileCollection().deleteMany({ defaultProfile: 'aaps-v3-test-other' });
});
});
});