Add MongoDB 8 compatibility follow-up fixes

This commit is contained in:
Andy Low
2026-03-06 21:35:16 +00:00
parent f441d6a6fa
commit faf578c08e
15 changed files with 627 additions and 26 deletions
+2 -2
View File
@@ -76,12 +76,12 @@ function Collection ({ ctx, env, app, colName, storageColName, fallbackGetDate,
* Parse limit (max document count) from query string
*/
self.parseLimit = function parseLimit (req, res) {
const maxLimit = app.get('API3_MAX_LIMIT');
const maxLimit = parseInt(app.get('API3_MAX_LIMIT'), 10) || apiConst.API3_MAX_LIMIT;
let limit = maxLimit;
if (req.query.limit) {
if (!isNaN(req.query.limit) && req.query.limit > 0 && req.query.limit <= maxLimit) {
limit = parseInt(req.query.limit);
limit = parseInt(req.query.limit, 10);
}
else {
opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_LIMIT);
+4 -5
View File
@@ -6,7 +6,7 @@ const _ = require('lodash')
, validate = require('./validate.js')
, opTools = require('../../shared/operationTools')
, dateTools = require('../../shared/dateTools')
, FieldsProjector = require('../../shared/fieldsProjector')
, treatmentDuration = require('../../../treatmentDuration')
;
/**
@@ -78,6 +78,8 @@ async function applyPatch (opCtx, identifier, doc, storageDoc) {
doc.modifiedBy = auth.subject.name;
}
treatmentDuration.normalizeTreatmentDuration(doc, storageDoc);
const matchedCount = await col.storage.updateOne(identifier, doc);
if (!matchedCount)
@@ -86,10 +88,7 @@ async function applyPatch (opCtx, identifier, doc, storageDoc) {
res.setHeader('Last-Modified', now.toUTCString());
opTools.sendJSONStatus(res, apiConst.HTTP.OK);
const fieldsProjector = new FieldsProjector('_all');
const patchedDocs = await col.storage.findOne(identifier, fieldsProjector);
const patchedDoc = patchedDocs[0];
fieldsProjector.applyProjection(patchedDoc);
const patchedDoc = Object.assign({}, storageDoc, doc);
ctx.bus.emit('storage-socket-update', { colName: col.colName, doc: patchedDoc });
col.autoPrune();
+2 -2
View File
@@ -121,7 +121,7 @@ function parseSkip (req, res) {
if (req.query.skip) {
if (!isNaN(req.query.skip) && req.query.skip >= 0) {
skip = parseInt(req.query.skip);
skip = parseInt(req.query.skip, 10);
}
else {
opTools.sendJSONStatus(res, apiConst.HTTP.BAD_REQUEST, apiConst.MSG.HTTP_400_BAD_SKIP);
@@ -137,4 +137,4 @@ module.exports = {
parseFilter,
parseSort,
parseSkip
};
};
+3
View File
@@ -5,6 +5,7 @@ const apiConst = require('../../const.json')
, validate = require('./validate.js')
, path = require('path')
, opTools = require('../../shared/operationTools')
, treatmentDuration = require('../../../treatmentDuration')
;
/**
@@ -32,6 +33,8 @@ async function replace (opCtx, doc, storageDoc, options) {
doc.subject = auth.subject.name;
}
treatmentDuration.normalizeTreatmentDuration(doc);
const matchedCount = await col.storage.replaceOne(storageDoc.identifier, doc);
if (!matchedCount)
+16 -2
View File
@@ -4,6 +4,18 @@ const utils = require('./utils')
, _ = require('lodash')
;
/**
* Ensure Mongo limit/skip receive integers even when callers pass env strings.
*/
function toSafeInt (value, defaultValue) {
if (value === null || value === undefined) {
return defaultValue;
}
const parsed = parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : defaultValue;
}
/**
* Find single document by identifier
@@ -73,11 +85,13 @@ function findMany (col, args) {
return new Promise(function (resolve, reject) {
const filter = utils.parseFilter(args.filter, logicalOperator, args.onlyValid);
const safeLimit = toSafeInt(args.limit, 1000);
const safeSkip = toSafeInt(args.skip, 0);
col.find(filter)
.sort(args.sort)
.limit(args.limit)
.skip(args.skip)
.limit(safeLimit)
.skip(safeSkip)
.project(args.projection)
.toArray(function mongoDone (err, result) {
+23 -1
View File
@@ -43,6 +43,27 @@ function init () {
&& !Object.prototype.hasOwnProperty.call(obj[key], 'mills')) {
obj[key].mills = new Date(obj[key].sysTime).getTime();
}
if (Object.prototype.hasOwnProperty.call(obj[key], 'durationInMilliseconds')
&& !Object.prototype.hasOwnProperty.call(obj[key], 'duration')) {
var durationInMilliseconds = Number(obj[key].durationInMilliseconds) || 0;
if (durationInMilliseconds > 0) {
obj[key].duration = Math.round(durationInMilliseconds / 60000);
}
}
if (!Object.prototype.hasOwnProperty.call(obj[key], 'endmills') || obj[key].endmills == null) {
var baseMills = Number(obj[key].mills);
if (Number.isFinite(baseMills)) {
obj[key].mills = baseMills;
if (Object.prototype.hasOwnProperty.call(obj[key], 'durationInMilliseconds')) {
var endmillsDuration = Number(obj[key].durationInMilliseconds) || 0;
if (endmillsDuration > 0) {
obj[key].endmills = baseMills + endmillsDuration;
}
} else if (Object.prototype.hasOwnProperty.call(obj[key], 'duration')) {
obj[key].endmills = baseMills + times.mins(Number(obj[key].duration) || 0).msecs;
}
}
}
}
});
@@ -65,7 +86,8 @@ function init () {
const oldElement = oldData[i];
let found = false;
for (let j = 0; j < newData.length; j++) {
if (oldElement._id == newData[j]._id) {
if ((oldElement._id && oldElement._id == newData[j]._id)
|| (oldElement.identifier && oldElement.identifier === newData[j].identifier)) {
found = true;
break;
}
+18 -10
View File
@@ -10,6 +10,20 @@ function getRemoteIP (req) {
return address.ip;
}
// Only coerce canonical 24-char hex strings to ObjectId.
// Preserve custom string ids and existing ObjectId instances.
function safeObjectID (id) {
if (id instanceof ObjectID) {
return id;
}
if (typeof id === 'string' && /^[0-9a-fA-F]{24}$/.test(id)) {
return new ObjectID(id);
}
return id;
}
function init (env, ctx, server) {
function websocket () {
@@ -214,13 +228,7 @@ function init (env, ctx, server) {
}
return;
}
var id;
try {
id = new ObjectID(data._id);
} catch (err) {
console.error(err);
id = new ObjectID();
}
var id = safeObjectID(data._id);
ctx.store.collection(collection).updateOne({ '_id': id }
, { $set: data.data }
@@ -269,7 +277,7 @@ function init (env, ctx, server) {
return;
}
var objId = new ObjectID(data._id);
var objId = safeObjectID(data._id);
ctx.store.collection(collection).updateOne({ '_id': objId }, { $unset: data.data }
, function(err, results) {
@@ -434,7 +442,7 @@ function init (env, ctx, server) {
if (array.length > 0) {
console.log(LOG_DEDUP + 'Found similiar', array[0]);
array[0].created_at = data.data.created_at;
var objId = new ObjectID(array[0]._id);
var objId = safeObjectID(array[0]._id);
ctx.store.collection(collection).updateOne({ '_id': objId }, { $set: { created_at: data.data.created_at } });
if (callback) {
callback([array[0]]);
@@ -553,7 +561,7 @@ function init (env, ctx, server) {
return;
}
var objId = new ObjectID(data._id);
var objId = safeObjectID(data._id);
ctx.store.collection(collection).deleteOne({ '_id': objId }
, function(err, stat) {
+73
View File
@@ -0,0 +1,73 @@
'use strict';
const times = require('./times');
function hasOwnProperty (obj, field) {
return obj && Object.prototype.hasOwnProperty.call(obj, field);
}
function toMills (value) {
if (value === null || typeof value === 'undefined') {
return null;
}
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value === 'string' && value.trim() === '') {
return null;
}
const numeric = Number(value);
if (Number.isFinite(numeric)) {
return numeric;
}
const dateMills = new Date(value).getTime();
return Number.isFinite(dateMills) ? dateMills : null;
}
function resolveBaseMills (doc, fallbackDoc) {
return toMills(doc && doc.mills)
?? toMills(fallbackDoc && fallbackDoc.mills)
?? toMills(doc && doc.created_at)
?? toMills(doc && doc.date)
?? toMills(fallbackDoc && fallbackDoc.created_at)
?? toMills(fallbackDoc && fallbackDoc.date);
}
function normalizeTreatmentDuration (doc, fallbackDoc) {
const baseMills = resolveBaseMills(doc, fallbackDoc);
if ((!hasOwnProperty(doc, 'endmills') || doc.endmills == null) && baseMills !== null) {
if (hasOwnProperty(doc, 'durationInMilliseconds')) {
const durationInMilliseconds = Number(doc.durationInMilliseconds) || 0;
if (durationInMilliseconds > 0) {
doc.endmills = baseMills + durationInMilliseconds;
}
} else if (hasOwnProperty(doc, 'duration')) {
doc.endmills = baseMills + times.mins(Number(doc.duration) || 0).msecs;
} else if (hasOwnProperty(fallbackDoc, 'durationInMilliseconds')) {
const durationInMilliseconds = Number(fallbackDoc.durationInMilliseconds) || 0;
if (durationInMilliseconds > 0) {
doc.endmills = baseMills + durationInMilliseconds;
}
} else if (hasOwnProperty(fallbackDoc, 'duration')) {
doc.endmills = baseMills + times.mins(Number(fallbackDoc.duration) || 0).msecs;
}
}
const endMills = hasOwnProperty(doc, 'endmills') ? Number(doc.endmills) : NaN;
if (Number.isFinite(baseMills) && Number.isFinite(endMills) && endMills >= baseMills) {
doc.durationInMilliseconds = endMills - baseMills;
doc.duration = Math.round(doc.durationInMilliseconds / 60000);
}
return doc;
}
module.exports = {
normalizeTreatmentDuration,
resolveBaseMills
};
+114
View File
@@ -0,0 +1,114 @@
'use strict';
require('should');
describe('API3 PATCH operation', function () {
const should = require('should');
const security = require('../lib/api3/security');
const patchOperation = require('../lib/api3/generic/patch/operation');
let originalAuthenticate;
let originalDemandPermission;
beforeEach(() => {
originalAuthenticate = security.authenticate;
originalDemandPermission = security.demandPermission;
});
afterEach(() => {
security.authenticate = originalAuthenticate;
security.demandPermission = originalDemandPermission;
});
it('emits the merged patched document without re-reading storage', async () => {
const date = 1741255200000;
const storageDoc = {
identifier: 'test-patch-operation',
date: date,
utcOffset: 0,
app: 'nightscout-test',
device: 'ns://pump',
eventType: 'Temp Basal',
absolute: 1.2,
duration: 30,
srvModified: date - 1000
};
const patchDoc = {
absolute: 0.7,
duration: 0,
durationInMilliseconds: 26584
};
const events = [];
security.authenticate = async () => ({ subject: { name: 'patch-user' } });
security.demandPermission = async () => true;
const col = {
colName: 'treatments',
storage: {
identifyingFilter: identifier => ({ identifier }),
findOneFilter: async () => [Object.assign({}, storageDoc)],
updateOne: async () => ({ updated: 1 }),
findOne: async () => {
throw new Error('PATCH should not re-read the document after update');
}
},
resolveDates: doc => new Date(doc.srvModified),
autoPrune: () => {
events.push({ name: 'auto-prune' });
}
};
const ctx = {
bus: {
emit: (name, payload) => {
events.push({ name, payload });
}
}
};
const req = {
body: Object.assign({}, patchDoc),
params: { identifier: storageDoc.identifier },
get: () => null
};
const res = createResponse();
const handler = patchOperation(ctx, {}, {}, col);
await handler(req, res);
res.statusCode.should.equal(200);
should.exist(res.headers['Last-Modified']);
const updateEvent = events.find(event => event.name === 'storage-socket-update');
should.exist(updateEvent);
updateEvent.payload.colName.should.equal('treatments');
updateEvent.payload.doc.should.containEql({
identifier: storageDoc.identifier,
absolute: 0.7,
duration: 0,
durationInMilliseconds: 26584,
modifiedBy: 'patch-user'
});
updateEvent.payload.doc.endmills.should.equal(date + 26584);
});
});
function createResponse () {
return {
headers: {},
headersSent: false,
statusCode: null,
body: null,
setHeader (name, value) {
this.headers[name] = value;
},
status (code) {
this.statusCode = code;
return this;
},
json (body) {
this.body = body;
this.headersSent = true;
return this;
}
};
}
+40 -1
View File
@@ -235,5 +235,44 @@ describe('API3 PATCH', function() {
self.cache.nextShouldEql(self.col, body)
});
});
it('should normalize endmills when patching durationInMilliseconds', async () => {
const tempBasalDoc = {
date: self.validDoc.date + 1,
utcOffset: -180,
app: testConst.TEST_APP,
device: testConst.TEST_DEVICE + ' API3 PATCH duration',
eventType: 'Temp Basal',
absolute: 1.2,
duration: 30
};
tempBasalDoc.identifier = opTools.calculateIdentifier(tempBasalDoc);
let res = await self.instance.post(`${self.url}`, self.jwt.create)
.send(tempBasalDoc)
.expect(201);
res.body.status.should.equal(201);
self.cache.nextShouldEql(self.col, tempBasalDoc)
res = await self.instance.patch(`${self.url}/${tempBasalDoc.identifier}`, self.jwt.update)
.send({
absolute: 0.7,
duration: 0,
durationInMilliseconds: 26584
})
.expect(200);
res.body.status.should.equal(200);
const body = await self.get(tempBasalDoc.identifier);
body.absolute.should.equal(0.7);
body.duration.should.equal(0);
body.durationInMilliseconds.should.equal(26584);
body.endmills.should.equal(tempBasalDoc.date + 26584);
body.modifiedBy.should.equal(self.subject.apiUpdate.name);
self.cache.nextShouldEql(self.col, body)
});
});
+13 -1
View File
@@ -277,5 +277,17 @@ describe('API3 SEARCH', function() {
apiApp.set('API3_MAX_LIMIT', limitBackup);
});
});
it('should respect string API3_MAX_LIMIT defaults', async () => {
const apiApp = self.instance.ctx.apiApp
, limitBackup = apiApp.get('API3_MAX_LIMIT');
apiApp.set('API3_MAX_LIMIT', '5');
let res = await self.instance.get(`${self.url}`, self.jwt.read)
.expect(200);
res.body.status.should.equal(200);
res.body.result.length.should.equal(5);
apiApp.set('API3_MAX_LIMIT', limitBackup);
});
});
+66
View File
@@ -0,0 +1,66 @@
'use strict';
require('should');
const find = require('../lib/api3/storage/mongoCollection/find');
describe('API3 mongoCollection findMany', function () {
function createStubCollection (observed) {
return {
find: function () {
return this;
},
sort: function () {
return this;
},
limit: function (value) {
observed.limit = value;
return this;
},
skip: function (value) {
observed.skip = value;
return this;
},
project: function () {
return this;
},
toArray: function (callback) {
callback(null, []);
}
};
}
it('coerces string limit and skip before calling Mongo', async function () {
const observed = {};
const col = createStubCollection(observed);
const result = await find.findMany(col, {
filter: [],
sort: { created_at: -1 },
limit: '5',
skip: '2',
projection: {}
});
observed.limit.should.equal(5);
observed.skip.should.equal(2);
result.should.eql([]);
});
it('coerces float-like values to integers before calling Mongo', async function () {
const observed = {};
const col = createStubCollection(observed);
await find.findMany(col, {
filter: [],
sort: { created_at: -1 },
limit: 5.9,
skip: 2.4,
projection: {}
});
observed.limit.should.equal(5);
observed.skip.should.equal(2);
});
});
+41 -1
View File
@@ -297,6 +297,47 @@ describe('API3 UPDATE', function() {
});
it('should normalize endmills when replacing durationInMilliseconds', async () => {
const tempBasalDoc = {
identifier: utils.randomString('32', 'aA#'),
date: (new Date()).getTime() + 1,
utcOffset: -180,
app: testConst.TEST_APP,
device: testConst.TEST_DEVICE + ' API3 UPDATE duration',
eventType: 'Temp Basal',
absolute: 1.2,
duration: 30
};
let res = await self.instance.put(`${self.url}/${tempBasalDoc.identifier}`, self.jwt.all)
.send(tempBasalDoc)
.expect(201);
res.body.status.should.equal(201);
self.cache.nextShouldEql(self.col, tempBasalDoc)
const replacedDoc = Object.assign({}, tempBasalDoc, {
absolute: 0.7,
duration: 0,
durationInMilliseconds: 26584
});
res = await self.instance.put(`${self.url}/${tempBasalDoc.identifier}`, self.jwt.update)
.send(replacedDoc)
.expect(200);
res.body.status.should.equal(200);
self.cache.nextShouldEql(self.col, replacedDoc)
const body = await self.get(tempBasalDoc.identifier);
body.absolute.should.equal(0.7);
body.duration.should.equal(0);
body.durationInMilliseconds.should.equal(26584);
body.endmills.should.equal(tempBasalDoc.date + 26584);
body.subject.should.equal(self.subject.apiUpdate.name);
});
it('should not update deleted document', async () => {
let res = await self.instance.delete(self.urlIdent, self.jwt.delete)
.expect(200);
@@ -312,4 +353,3 @@ describe('API3 UPDATE', function() {
});
});
+26 -1
View File
@@ -41,6 +41,32 @@ describe('ddata', function ( ) {
done( );
});
it('processRawDataForRuntime derives duration and endmills from durationInMilliseconds', function () {
var ddata = require('../lib/data/ddata')();
var createdAt = '2026-03-06T10:00:00.000Z';
var result = ddata.processRawDataForRuntime([{
_id: '507f1f77bcf86cd799439011',
created_at: createdAt,
durationInMilliseconds: 26584
}])[0];
result.mills.should.equal(new Date(createdAt).getTime());
result.duration.should.equal(0);
result.endmills.should.equal(result.mills + 26584);
});
it('idMergePreferNew matches records by identifier when _id is missing', function () {
var ddata = require('../lib/data/ddata')();
var merged = ddata.idMergePreferNew(
[{ _id: 'mongo-id', identifier: 'loop-id', carbs: 15 }],
[{ identifier: 'loop-id', carbs: 0 }]
);
merged.length.should.equal(1);
merged[0].carbs.should.equal(0);
merged[0].identifier.should.equal('loop-id');
});
// TODO: ensure partition function gets called via:
// Properties
// * ddata.devicestatus
@@ -57,4 +83,3 @@ describe('ddata', function ( ) {
});
+186
View File
@@ -75,6 +75,10 @@ describe('WebSocket Shape Handling - dbAdd Single vs Array Input', function () {
});
}
function treatmentsCollection() {
return self.ctx.store.collection(self.env.treatments_collection);
}
describe('dbAdd with treatments collection', function () {
beforeEach(function (done) {
@@ -261,6 +265,97 @@ describe('WebSocket Shape Handling - dbAdd Single vs Array Input', function () {
});
});
});
it('dbUpdate supports custom string _id values', function (done) {
connectAndAuthorize(function (err, socket) {
if (err) return done(err);
var legacyId = 'legacy-string-id-update';
var createdAt = new Date().toISOString();
treatmentsCollection().insertOne({
_id: legacyId,
eventType: 'Note',
created_at: createdAt,
notes: 'legacy original'
}, function (insertErr) {
if (insertErr) return done(insertErr);
socket.emit('dbUpdate', {
collection: 'treatments',
_id: legacyId,
data: {
notes: 'legacy updated'
}
}, function (updateResult) {
should.exist(updateResult);
updateResult.result.should.equal('success');
waitForConditionWithWarning({
condition: function (cb) {
treatmentsCollection().findOne({ _id: legacyId }, cb);
},
assertion: function (doc) {
should.exist(doc);
doc.notes.should.equal('legacy updated');
},
done: done,
operationName: 'verify websocket dbUpdate with custom string _id'
});
});
});
});
});
});
describe('dbUpdateUnset operations', function () {
beforeEach(function (done) {
self.ctx.treatments.remove({ find: { created_at: { '$gte': '1999-01-01T00:00:00.000Z' } } }, function () {
done();
});
});
it('dbUpdateUnset supports custom string _id values', function (done) {
connectAndAuthorize(function (err, socket) {
if (err) return done(err);
var legacyId = 'legacy-string-id-unset';
var createdAt = new Date().toISOString();
treatmentsCollection().insertOne({
_id: legacyId,
eventType: 'Note',
created_at: createdAt,
notes: 'remove me'
}, function (insertErr) {
if (insertErr) return done(insertErr);
socket.emit('dbUpdateUnset', {
collection: 'treatments',
_id: legacyId,
data: {
notes: 1
}
}, function (updateResult) {
should.exist(updateResult);
updateResult.result.should.equal('success');
waitForConditionWithWarning({
condition: function (cb) {
treatmentsCollection().findOne({ _id: legacyId }, cb);
},
assertion: function (doc) {
should.exist(doc);
should.not.exist(doc.notes);
},
done: done,
operationName: 'verify websocket dbUpdateUnset with custom string _id'
});
});
});
});
});
});
describe('dbRemove operations', function () {
@@ -300,6 +395,97 @@ describe('WebSocket Shape Handling - dbAdd Single vs Array Input', function () {
});
});
});
it('dbRemove supports custom string _id values', function (done) {
connectAndAuthorize(function (err, socket) {
if (err) return done(err);
var legacyId = 'legacy-string-id-remove';
var createdAt = new Date().toISOString();
treatmentsCollection().insertOne({
_id: legacyId,
eventType: 'Note',
created_at: createdAt,
notes: 'delete me'
}, function (insertErr) {
if (insertErr) return done(insertErr);
socket.emit('dbRemove', {
collection: 'treatments',
_id: legacyId
}, function (removeResult) {
should.exist(removeResult);
removeResult.result.should.equal('success');
waitForConditionWithWarning({
condition: function (cb) {
treatmentsCollection().findOne({ _id: legacyId }, cb);
},
assertion: function (doc) {
should.not.exist(doc);
},
done: done,
operationName: 'verify websocket dbRemove with custom string _id'
});
});
});
});
});
});
describe('dbAdd dedupe operations', function () {
beforeEach(function (done) {
self.ctx.treatments.remove({ find: { created_at: { '$gte': '1999-01-01T00:00:00.000Z' } } }, function () {
done();
});
});
it('dbAdd dedupe updates custom string _id records without ObjectId conversion', function (done) {
connectAndAuthorize(function (err, socket) {
if (err) return done(err);
var legacyId = 'legacy-string-id-dedupe';
var originalCreatedAt = new Date().toISOString();
var dedupedCreatedAt = new Date(Date.now() + 1000).toISOString();
treatmentsCollection().insertOne({
_id: legacyId,
eventType: 'Note',
created_at: originalCreatedAt,
notes: 'existing legacy note'
}, function (insertErr) {
if (insertErr) return done(insertErr);
socket.emit('dbAdd', {
collection: 'treatments',
data: {
eventType: 'Note',
created_at: dedupedCreatedAt,
notes: 'incoming legacy note'
}
}, function (result) {
should.exist(result);
result.should.be.instanceof(Array);
result.length.should.equal(1);
result[0]._id.should.equal(legacyId);
waitForConditionWithWarning({
condition: function (cb) {
treatmentsCollection().findOne({ _id: legacyId }, cb);
},
assertion: function (doc) {
should.exist(doc);
doc.created_at.should.equal(dedupedCreatedAt);
},
done: done,
operationName: 'verify websocket dbAdd dedupe with custom string _id'
});
});
});
});
});
});
});