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',
|
||||
'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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user