mirror of
https://github.com/bckelley/cgm-remote-monitor.git
synced 2026-08-24 03:14:12 -05:00
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:
@@ -21,6 +21,15 @@ function validate (opCtx, doc, storageDoc, options) {
|
|||||||
const immutable = ['identifier', 'date', 'utcOffset', 'eventType', 'device', 'app',
|
const immutable = ['identifier', 'date', 'utcOffset', 'eventType', 'device', 'app',
|
||||||
'srvCreated', 'subject', 'srvModified', 'modifiedBy', 'isValid'];
|
'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) {
|
if (storageDoc.isReadOnly === true || storageDoc.readOnly === true || storageDoc.readonly === true) {
|
||||||
return opTools.sendJSONStatus(res, apiConst.HTTP.UNPROCESSABLE_ENTITY,
|
return opTools.sendJSONStatus(res, apiConst.HTTP.UNPROCESSABLE_ENTITY,
|
||||||
apiConst.MSG.HTTP_422_READONLY_MODIFICATION);
|
apiConst.MSG.HTTP_422_READONLY_MODIFICATION);
|
||||||
@@ -36,6 +45,9 @@ function validate (opCtx, doc, storageDoc, options) {
|
|||||||
if (storageDoc.isValid === false)
|
if (storageDoc.isValid === false)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
if (isProfileStoreDedup && profileStoreMutable.has(field))
|
||||||
|
continue;
|
||||||
|
|
||||||
if (typeof(doc[field]) !== 'undefined' && doc[field] !== storageDoc[field]) {
|
if (typeof(doc[field]) !== 'undefined' && doc[field] !== storageDoc[field]) {
|
||||||
return opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST,
|
return opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST,
|
||||||
apiConst.MSG.HTTP_400_IMMUTABLE_FIELD.replace('{0}', field));
|
apiConst.MSG.HTTP_400_IMMUTABLE_FIELD.replace('{0}', field));
|
||||||
|
|||||||
@@ -98,6 +98,17 @@ function calculateIdentifier (doc) {
|
|||||||
if (!doc)
|
if (!doc)
|
||||||
return undefined;
|
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;
|
let key = doc.device + '_' + doc.date;
|
||||||
if (doc.eventType) {
|
if (doc.eventType) {
|
||||||
key += '_' + doc.eventType;
|
key += '_' + doc.eventType;
|
||||||
|
|||||||
@@ -652,8 +652,11 @@ describe('API3 AAPS Patterns - Deduplication and Real-world Scenarios', function
|
|||||||
docs.length.should.equal(1);
|
docs.length.should.equal(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AAPS edit (new date from LocalProfileLastChange) creates a SECOND doc, not an update', async () => {
|
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
|
// 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 t1 = Date.now() - 60000;
|
||||||
const first = aapsV3Profile(t1);
|
const first = aapsV3Profile(t1);
|
||||||
first.store['aaps-v3-test'].carbratio[0].value = 8;
|
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);
|
res1.status.should.equal(201);
|
||||||
self.cache.clear();
|
self.cache.clear();
|
||||||
|
|
||||||
const res2 = await self.instance.post(url, self.jwt.create).send(second);
|
const res2 = await self.instance.post(url, self.jwt.update).send(second);
|
||||||
// V3 inserts a NEW doc because identifier (uuidv5 of "undefined_<date>") differs
|
// Post-fix: edit dedups in place -> 200, same identifier
|
||||||
res2.status.should.equal(201);
|
res2.status.should.equal(200);
|
||||||
res2.body.identifier.should.not.equal(res1.body.identifier);
|
res2.body.identifier.should.equal(res1.body.identifier);
|
||||||
|
|
||||||
const docs = await profileCollection().find({ defaultProfile: 'aaps-v3-test' }).toArray();
|
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) => {
|
await new Promise((resolve, reject) => {
|
||||||
self.instance.ctx.profile.last((err, lastDocs) => {
|
self.instance.ctx.profile.last((err, lastDocs) => {
|
||||||
if (err) return reject(err);
|
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' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user