All checks were successful
farmcontrol/farmcontrol-ws/pipeline/head This commit looks good
- Implemented `subscribeToAllObjectUpdates` and `unsubscribeAllObjectUpdates` methods in the `UpdateManager` to handle bulk updates for object types. - Updated `SocketHost` and `SocketUser` classes to listen for new socket events related to all object updates. - Enhanced test coverage for the new subscription methods to ensure correct functionality and integration with the socket client.
420 lines
12 KiB
JavaScript
420 lines
12 KiB
JavaScript
import log4js from 'log4js';
|
|
import _ from 'lodash';
|
|
import { loadConfig } from '../config.js';
|
|
import { natsServer } from '../database/nats.js';
|
|
import { expandObjectIds } from '../database/utils.js';
|
|
import { getFilter, getObjectTypeModel } from '../database/filter.js';
|
|
import { formatTraceData } from '../utils.js';
|
|
const config = loadConfig();
|
|
|
|
// Setup logger
|
|
const logger = log4js.getLogger('Update Manager');
|
|
logger.level = config.server.logLevel;
|
|
|
|
const normalizeFilter = filter =>
|
|
filter && typeof filter === 'object' && !Array.isArray(filter) ? filter : {};
|
|
|
|
const unwrapId = value => {
|
|
if (
|
|
value &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value) &&
|
|
value._id != null
|
|
) {
|
|
return value._id;
|
|
}
|
|
return value;
|
|
};
|
|
|
|
const getFilterValue = (object, key) => {
|
|
if (key.endsWith('._id')) {
|
|
const refPath = key.slice(0, -4);
|
|
const ref = _.get(object, refPath);
|
|
if (ref && typeof ref === 'object' && ref._id) {
|
|
return ref._id;
|
|
}
|
|
return ref;
|
|
}
|
|
|
|
return unwrapId(_.get(object, key));
|
|
};
|
|
|
|
const valuesMatch = (actual, expected) => {
|
|
const left = unwrapId(actual);
|
|
const right = unwrapId(expected);
|
|
|
|
if (left == right) {
|
|
return true;
|
|
}
|
|
|
|
if (left != null && right != null) {
|
|
return String(left) === String(right);
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const isOperatorObject = value => {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return false;
|
|
}
|
|
if (value instanceof Date || value._bsontype === 'ObjectId') {
|
|
return false;
|
|
}
|
|
const keys = Object.keys(value);
|
|
return keys.length > 0 && keys.every(key => key.startsWith('$'));
|
|
};
|
|
|
|
const compareValues = (actual, expected, operator) => {
|
|
if (actual == null || expected == null) {
|
|
return false;
|
|
}
|
|
if (operator === '$gt') return actual > expected;
|
|
if (operator === '$gte') return actual >= expected;
|
|
if (operator === '$lt') return actual < expected;
|
|
if (operator === '$lte') return actual <= expected;
|
|
return false;
|
|
};
|
|
|
|
const matchesOperators = (actual, operators) => {
|
|
if (Object.prototype.hasOwnProperty.call(operators, '$eq')) {
|
|
if (!valuesMatch(actual, operators.$eq)) return false;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(operators, '$ne')) {
|
|
if (valuesMatch(actual, operators.$ne)) return false;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(operators, '$in')) {
|
|
if (
|
|
!Array.isArray(operators.$in) ||
|
|
!operators.$in.some(value => valuesMatch(actual, value))
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(operators, '$nin')) {
|
|
if (
|
|
Array.isArray(operators.$nin) &&
|
|
operators.$nin.some(value => valuesMatch(actual, value))
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(operators, '$regex')) {
|
|
const regex = new RegExp(operators.$regex, operators.$options || '');
|
|
if (!regex.test(String(actual ?? ''))) return false;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(operators, '$not')) {
|
|
if (matchesExpected(actual, operators.$not)) return false;
|
|
}
|
|
for (const operator of ['$gt', '$gte', '$lt', '$lte']) {
|
|
if (
|
|
Object.prototype.hasOwnProperty.call(operators, operator) &&
|
|
!compareValues(actual, operators[operator], operator)
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const matchesExpected = (actual, expected) => {
|
|
if (isOperatorObject(expected)) {
|
|
return matchesOperators(actual, expected);
|
|
}
|
|
return valuesMatch(actual, expected);
|
|
};
|
|
|
|
const matchesFilter = (object, filter) => {
|
|
if (!filter || Object.keys(filter).length === 0) {
|
|
return true;
|
|
}
|
|
|
|
if (object == null) {
|
|
return false;
|
|
}
|
|
|
|
const normalizedObject =
|
|
typeof object === 'object' && !Array.isArray(object)
|
|
? object
|
|
: { _id: object };
|
|
|
|
if (Array.isArray(filter.$and)) {
|
|
return filter.$and.every(clause => matchesFilter(normalizedObject, clause));
|
|
}
|
|
if (Array.isArray(filter.$or)) {
|
|
return filter.$or.some(clause => matchesFilter(normalizedObject, clause));
|
|
}
|
|
|
|
for (const [key, expectedValue] of Object.entries(filter)) {
|
|
if (key === '$and' || key === '$or') {
|
|
continue;
|
|
}
|
|
if (
|
|
!matchesExpected(getFilterValue(normalizedObject, key), expectedValue)
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const resolveObjectTypeFilter = async (objectType, filter = {}) => {
|
|
const normalizedFilter = normalizeFilter(filter);
|
|
if (Object.keys(normalizedFilter).length === 0) {
|
|
return { normalizedFilter, processedFilter: normalizedFilter };
|
|
}
|
|
|
|
const model = await getObjectTypeModel(objectType);
|
|
const processedFilter = await getFilter(
|
|
normalizedFilter,
|
|
Object.keys(normalizedFilter),
|
|
true,
|
|
model
|
|
);
|
|
return { normalizedFilter, processedFilter };
|
|
};
|
|
|
|
const stableStringify = value => {
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map(stableStringify).join(',')}]`;
|
|
}
|
|
|
|
if (value && typeof value === 'object') {
|
|
return `{${Object.keys(value)
|
|
.sort()
|
|
.map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
|
.join(',')}}`;
|
|
}
|
|
|
|
return JSON.stringify(value);
|
|
};
|
|
|
|
const getSubscriptionOwner = (socketId, filter) =>
|
|
`${socketId}:${stableStringify(normalizeFilter(filter))}`;
|
|
|
|
const getSubscriptionKey = (subject, owner) => `${subject}:${owner}`;
|
|
|
|
/**
|
|
* UpdateManager handles tracking object updates and broadcasts update events via websockets.
|
|
*/
|
|
export class UpdateManager {
|
|
constructor(socketClient) {
|
|
this.socketClient = socketClient;
|
|
this.subscriptions = new Set();
|
|
this.objectUpdateSubscriptions = new Set();
|
|
}
|
|
|
|
getObjectUpdateSubscriptionKey(objectType, id) {
|
|
return `${objectType}:${id}`;
|
|
}
|
|
|
|
matchesObjectTypeFilter(objectType, filter, value) {
|
|
return matchesFilter(value, normalizeFilter(filter));
|
|
}
|
|
|
|
emitObjectTypeEvent(
|
|
eventName,
|
|
objectType,
|
|
filter,
|
|
value,
|
|
matchFilter = filter
|
|
) {
|
|
const normalizedFilter = normalizeFilter(filter);
|
|
const matches = this.matchesObjectTypeFilter(
|
|
objectType,
|
|
matchFilter,
|
|
value
|
|
);
|
|
|
|
if (!matches) {
|
|
logger.trace(
|
|
`Filtered ${eventName} event: ${formatTraceData({
|
|
objectType,
|
|
filter: normalizedFilter,
|
|
value
|
|
})}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.socketClient.socket.emit(eventName, {
|
|
object: value,
|
|
objectType: objectType,
|
|
filter: normalizedFilter
|
|
});
|
|
}
|
|
|
|
async subscribeToObjectNew(objectType, filter = {}) {
|
|
const { normalizedFilter, processedFilter } = await resolveObjectTypeFilter(
|
|
objectType,
|
|
filter
|
|
);
|
|
const subject = `${objectType}s.new`;
|
|
const owner = getSubscriptionOwner(
|
|
this.socketClient.socketId,
|
|
normalizedFilter
|
|
);
|
|
|
|
await natsServer.subscribe(subject, owner, async (key, value) => {
|
|
logger.trace(`Object new event: ${formatTraceData(value)}`);
|
|
this.emitObjectTypeEvent(
|
|
'objectNew',
|
|
objectType,
|
|
normalizedFilter,
|
|
value,
|
|
processedFilter
|
|
);
|
|
});
|
|
|
|
this.subscriptions.add(getSubscriptionKey(subject, owner));
|
|
return { success: true };
|
|
}
|
|
|
|
async subscribeToObjectDelete(objectType, filter = {}) {
|
|
const { normalizedFilter, processedFilter } = await resolveObjectTypeFilter(
|
|
objectType,
|
|
filter
|
|
);
|
|
const subject = `${objectType}s.delete`;
|
|
const owner = getSubscriptionOwner(
|
|
this.socketClient.socketId,
|
|
normalizedFilter
|
|
);
|
|
|
|
await natsServer.subscribe(subject, owner, async (key, value) => {
|
|
logger.trace(`Object delete event: ${formatTraceData(value)}`);
|
|
this.emitObjectTypeEvent(
|
|
'objectDelete',
|
|
objectType,
|
|
normalizedFilter,
|
|
value,
|
|
processedFilter
|
|
);
|
|
});
|
|
|
|
this.subscriptions.add(getSubscriptionKey(subject, owner));
|
|
return { success: true };
|
|
}
|
|
|
|
async subscribeToObjectUpdate(id, objectType) {
|
|
logger.debug('Subscribing to object update...', id, objectType);
|
|
const subject = `${objectType}s.${id}.object`;
|
|
const owner = this.socketClient.socketId;
|
|
|
|
await natsServer.subscribe(subject, owner, (key, value) => {
|
|
const expandedValue = expandObjectIds(value);
|
|
logger.trace('Object update event:', id);
|
|
this.socketClient.socket.emit('objectUpdate', {
|
|
_id: id,
|
|
objectType: objectType,
|
|
object: { ...expandedValue }
|
|
});
|
|
});
|
|
|
|
this.objectUpdateSubscriptions.add(
|
|
this.getObjectUpdateSubscriptionKey(objectType, id)
|
|
);
|
|
this.subscriptions.add(getSubscriptionKey(subject, owner));
|
|
return { success: true };
|
|
}
|
|
|
|
extractIdFromUpdateSubject(subject) {
|
|
const parts = subject.split('.');
|
|
if (parts.length < 3 || parts[parts.length - 1] !== 'object') {
|
|
return null;
|
|
}
|
|
return parts[parts.length - 2];
|
|
}
|
|
|
|
async subscribeToAllObjectUpdates(objectType) {
|
|
logger.debug('Subscribing to all object updates...', objectType);
|
|
const subject = `${objectType}s.*.object`;
|
|
const owner = this.socketClient.socketId;
|
|
|
|
await natsServer.subscribe(subject, owner, (key, value) => {
|
|
const id = this.extractIdFromUpdateSubject(key);
|
|
if (!id) {
|
|
logger.warn('Unable to extract id from update subject:', key);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
this.objectUpdateSubscriptions.has(
|
|
this.getObjectUpdateSubscriptionKey(objectType, id)
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const expandedValue = expandObjectIds(value);
|
|
logger.trace('All object update event:', id, objectType);
|
|
this.socketClient.socket.emit('objectUpdate', {
|
|
_id: id,
|
|
objectType: objectType,
|
|
object: { ...expandedValue }
|
|
});
|
|
});
|
|
|
|
this.subscriptions.add(getSubscriptionKey(subject, owner));
|
|
return { success: true };
|
|
}
|
|
|
|
async removeObjectNewListener(objectType, filter = {}) {
|
|
const subject = `${objectType}s.new`;
|
|
const owner = getSubscriptionOwner(this.socketClient.socketId, filter);
|
|
|
|
await natsServer.removeSubscription(subject, owner);
|
|
this.subscriptions.delete(getSubscriptionKey(subject, owner));
|
|
return { success: true };
|
|
}
|
|
|
|
async removeObjectDeleteListener(objectType, filter = {}) {
|
|
const subject = `${objectType}s.delete`;
|
|
const owner = getSubscriptionOwner(this.socketClient.socketId, filter);
|
|
|
|
await natsServer.removeSubscription(subject, owner);
|
|
this.subscriptions.delete(getSubscriptionKey(subject, owner));
|
|
return { success: true };
|
|
}
|
|
|
|
async removeObjectUpdateListener(id, objectType) {
|
|
const subject = `${objectType}s.${id}.object`;
|
|
const owner = this.socketClient.socketId;
|
|
|
|
await natsServer.removeSubscription(subject, owner);
|
|
this.objectUpdateSubscriptions.delete(
|
|
this.getObjectUpdateSubscriptionKey(objectType, id)
|
|
);
|
|
this.subscriptions.delete(getSubscriptionKey(subject, owner));
|
|
return { success: true };
|
|
}
|
|
|
|
async removeAllObjectUpdatesListener(objectType) {
|
|
const subject = `${objectType}s.*.object`;
|
|
const owner = this.socketClient.socketId;
|
|
|
|
await natsServer.removeSubscription(subject, owner);
|
|
this.subscriptions.delete(getSubscriptionKey(subject, owner));
|
|
return { success: true };
|
|
}
|
|
|
|
async removeAllListeners() {
|
|
logger.debug('Removing all update listeners...');
|
|
const removePromises = Array.from(this.subscriptions).map(
|
|
subscriptionKey => {
|
|
const separatorIndex = subscriptionKey.indexOf(':');
|
|
const subject = subscriptionKey.slice(0, separatorIndex);
|
|
const owner = subscriptionKey.slice(separatorIndex + 1);
|
|
return natsServer.removeSubscription(subject, owner);
|
|
}
|
|
);
|
|
|
|
await Promise.all(removePromises);
|
|
this.subscriptions.clear();
|
|
this.objectUpdateSubscriptions.clear();
|
|
logger.debug(`Removed ${removePromises.length} update listener(s)`);
|
|
return { success: true };
|
|
}
|
|
}
|