farmcontrol-ws/src/updates/updatemanager.js
Tom Butcher ffcc7cd0aa
All checks were successful
farmcontrol/farmcontrol-ws/pipeline/head This commit looks good
Add support for subscribing and unsubscribing to all object updates
- 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.
2026-09-01 14:14:18 +01:00

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 };
}
}