@camunda8/sdk
Version:
[](https://www.npmjs.com/package/@camunda8/sdk)
396 lines • 17.7 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.QuerySubscription = QuerySubscription;
const node_events_1 = __importDefault(require("node:events"));
const node_util_1 = require("node:util");
const debug_1 = __importDefault(require("debug"));
const AsyncTrace_1 = require("./AsyncTrace");
/**
* Generates a hash code from a string using the djb2 algorithm.
* This is faster than JSON.stringify for comparison purposes.
* We store it as a base 36 string representation to reduce size.
* @param str String to hash
* @returns A number hash of the string
*/
function hashString(str) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) + hash + str.charCodeAt(i); /* hash * 33 + c */
}
return hash.toString(36);
}
/**
* Creates a hash of an object by first converting to JSON string and then hashing
* @param obj Object to hash
* @returns A string hash representing the object
*/
function hashObject(obj) {
return hashString(JSON.stringify(obj));
}
const debug = (0, debug_1.default)('camunda:querySubscription');
function QuerySubscription(options) {
return new _QuerySubscription(options);
}
/**
* @description The default predicate function for QuerySubscription.
* It checks if the current query result has new items compared to the previous state.
* If there are new items, it returns the current state with only the new items and updates the totalItems count.
* If there are no new items, it returns false, indicating that no update should be emitted.
* @param previous the previous state of the query result
* @param current the current state of the query result
* @returns - If there are new items, returns the current state with only the new items and updated totalItems count.
* - If there are no new items, returns false, indicating that no update should be emitted.
*/
function defaultPredicate(previous, current) {
// Handle missing items arrays gracefully
if (!current || !current.items)
return false;
const previousItems = (previous?.items ?? []);
const currentItems = current.items.filter((item) => !previousItems.some((prevItem) => (0, node_util_1.isDeepStrictEqual)(prevItem, item)));
if (currentItems.length > 0) {
const result = {
...current,
items: currentItems,
};
// Update page totalItems if it exists
if (current.page) {
result.page = { ...current.page, totalItems: currentItems.length };
}
return result;
}
return false; // No new items, do not emit
}
/**
* @description QuerySubscription is a utility class that allows you to subscribe to a query and receive updates when the query result changes.
* It is useful for polling operations where you want to receive updates when the result of a query changes, such as when a process instance is created or updated.
* It uses a predicate function to determine whether to emit an update event. When using the Orchestration Cluster API, the default predicate checks if the result has new items compared to the previous state.
* The predicate function receives the previous state and the current state of the query result and should return a value that indicates whether to emit an update.
* If the predicate returns `true`, the current state is emitted. If it returns an object, that object is emitted as the update.
* If it returns `false`, no update is emitted.
* @experimental This is an experimental feature and may change in the future.
* It is not yet stable and may have breaking changes in future releases. We're still working on it, and we welcome feedback.
* Please use it with caution and be prepared for potential changes.
* @example
* ```ts
* const query = () =>
* c8.searchProcessInstances({
* filter: {
* processDefinitionKey: key,
* state: 'ACTIVE',
* },
* sort: [{ field: 'startDate', order: 'ASC' }],
* })
*
* const subscription = QuerySubscription({
* query,
* predicate: (previous, current) => { // This is the default predicate, shown here for clarity
* const previousItems = (previous?.items ?? []) as Array<unknown>
* const currentItems = current.items.filter(
* (item) =>
* !previousItems.some((prevItem) => isDeepStrictEqual(prevItem, item))
* )
* if (currentItems.length > 0) {
* return {
* ...current,
* items: currentItems,
* page: { ...current.page, totalItems: currentItems.length },
* }
* }
* return false // No new items, do not emit
* },
* interval: 500,
* })
* subscription.on('update', (data) => {
* console.log('Received new processes:', data.items)
* })
* // After some time
* subscription.stop() // Stop polling when no longer needed, you can also call `start()` to resume polling
* subscription.cancel() // Or cancel the subscription to free resources
* ```
* @see {@link PollingOperation} for a simpler polling operation that does a single query.
*/
class _QuerySubscription {
constructor(options) {
/** The current state of the query, used to compare with the next result */
this._state = undefined;
this._pollHandle = null;
/** We prevent further polling while we are calculating the predicate */
this._predicateLock = false;
/** We prevent further polling while we are processing the current poll */
this._pollLock = false;
/** Track items we've emitted to prevent duplicates within a limited window of poll cycles */
this._recentEmittedItems = new Map();
/** Current poll cycle number, used for the rolling window of tracked items */
this._currentPollCycle = 0;
this._query = options.query;
this._trackingWindow =
options.trackingWindow !== undefined ? options.trackingWindow : 5;
debug(`Created with interval ${options.interval || 1000}ms and tracking window ${this._trackingWindow} cycles`);
if ('predicate' in options && options.predicate) {
this._predicate = options.predicate;
debug(`Using custom predicate`);
}
else {
// Use type assertion to handle the default predicate
this._predicate = defaultPredicate;
debug(`Using default predicate`);
}
this._interval = options.interval || 1000;
this.resume();
this.emitter = new node_events_1.default();
// Delegate all EventEmitter methods to the internal emitter
this.on = this.emitter.on.bind(this.emitter);
this.off = this.emitter.off.bind(this.emitter);
this.once = this.emitter.once.bind(this.emitter);
this.emit = this.emitter.emit.bind(this.emitter);
this.removeListener = this.emitter.removeListener.bind(this.emitter);
this.removeAllListeners = this.emitter.removeAllListeners.bind(this.emitter);
this.prependListener = this.emitter.prependListener.bind(this.emitter);
this.prependOnceListener = this.emitter.prependOnceListener.bind(this.emitter);
this.listeners = this.emitter.listeners.bind(this.emitter);
}
pause() {
if (!this._pollHandle) {
return;
}
clearInterval(this._pollHandle);
this._pollHandle = null;
this._pollLock = false;
}
cancel() {
this.pause();
this._state = undefined;
this._poll = undefined;
this._predicateLock = false;
this._pollLock = false;
this._currentPollCycle = 0;
this._recentEmittedItems.clear();
this.removeAllListeners();
}
resume() {
if (this._pollHandle) {
return;
}
this._pollHandle = setInterval(() => (0, AsyncTrace_1.runWithAsyncErrorContext)(this.poll.bind(this), 'QuerySubscription'), this._interval);
}
/**
* Gets the hash for an item, used for tracking across poll cycles
* @param item The item to hash
* @returns The hash string for the item
*/
getItemHash(item) {
return hashObject(item);
}
/**
* Gets the item set for a specific poll cycle
* @param cycleOffset The offset from the current poll cycle
* @returns The set of item hashes for that cycle, or undefined if none exist
*/
getCycleItemSet(cycleOffset) {
// If tracking window is disabled, return undefined
if (this._trackingWindow <= 0)
return undefined;
// Calculate the actual cycle index with a safe modulo operation
const normalizedOffset = (this._currentPollCycle - cycleOffset + this._trackingWindow) %
this._trackingWindow;
return this._recentEmittedItems.get(normalizedOffset);
}
/**
* Check if an item has already been emitted within the tracking window to prevent duplicates
*/
hasEmittedItem(item) {
// If tracking window is disabled (set to 0), we don't track items
if (this._trackingWindow <= 0)
return false;
// Generate a hash of the item
const itemHash = this.getItemHash(item);
// Check in all recent poll cycles
for (let i = 0; i < this._trackingWindow; i++) {
const itemSet = this.getCycleItemSet(i);
if (itemSet && itemSet.has(itemHash)) {
if (debug.enabled) {
// Check if debug is enabled before logging - this impacts performance
// For debugging, we'll still include a short preview of the item
const itemPreview = JSON.stringify(item).substring(0, 50) + '...';
debug(`Item already emitted within last ${i + 1} poll cycles: ${itemPreview}`);
}
return true;
}
}
return false;
}
/**
* Gets or creates an item set for the current poll cycle
* @returns The set of item hashes for the current cycle
*/
getCurrentCycleItemSet() {
let currentSet = this._recentEmittedItems.get(this._currentPollCycle);
if (!currentSet) {
currentSet = new Set();
this._recentEmittedItems.set(this._currentPollCycle, currentSet);
}
return currentSet;
}
/**
* Advances the poll cycle and cleans up old data
*/
advancePollCycle() {
// If tracking window is disabled, do nothing
if (this._trackingWindow <= 0)
return;
// Increment the poll cycle
this._currentPollCycle = (this._currentPollCycle + 1) % this._trackingWindow;
// Remove old data from the next cycle slot that we'll use in the future
this._recentEmittedItems.delete(this._currentPollCycle);
debug(`Advanced to poll cycle ${this._currentPollCycle}`);
}
/**
* Mark an item as emitted to prevent duplicates within the tracking window
*/
markItemAsEmitted(item) {
// If tracking window is disabled, do nothing
if (this._trackingWindow <= 0)
return;
// Generate a hash of the item
const itemHash = this.getItemHash(item);
// For debugging, we'll still include a short preview of the item
if (debug.enabled) {
const itemPreview = JSON.stringify(item).substring(0, 50) + '...';
debug(`Marking item as emitted in cycle ${this._currentPollCycle}: ${itemPreview}`);
}
// Get or create the Set for the current poll cycle and add the item
const currentSet = this.getCurrentCycleItemSet();
currentSet.add(itemHash);
}
/**
* Safely emit items, preventing duplicates
*/
safeEmit(data) {
// Handle items array if present
if (data && typeof data === 'object' && 'items' in data) {
const dataWithItems = data;
// Check if we already emitted any of these items
const newItems = dataWithItems.items.filter((item) => !this.hasEmittedItem(item));
if (newItems.length === 0) {
debug(`All items have already been emitted, skipping update`);
return;
}
if (newItems.length !== dataWithItems.items.length) {
debug(`Filtered out ${dataWithItems.items.length - newItems.length} duplicate items`);
// Create a new object with only the new items
const filteredData = {
...data,
items: newItems,
};
if ('page' in filteredData && typeof filteredData.page === 'object') {
filteredData.page = {
...filteredData.page,
totalItems: newItems.length,
};
}
// Mark all items as emitted
newItems.forEach((item) => this.markItemAsEmitted(item));
// Emit the filtered data
this.emit('update', filteredData);
return;
}
// Mark all items as emitted
dataWithItems.items.forEach((item) => this.markItemAsEmitted(item));
}
// Emit the data
this.emit('update', data);
}
async poll() {
// Skip polling if locks are already set or if there are no listeners
if (this._pollLock ||
this._predicateLock ||
this.listeners('update').length === 0) {
debug(`Poll skipped: locks=${this._pollLock},${this._predicateLock}, listeners=${this.listeners('update').length}`);
return;
}
// Acquire locks before any async operation
this._pollLock = true;
this._predicateLock = true;
try {
// Advance the poll cycle and clean up old data if tracking is enabled
this.advancePollCycle();
debug(`Poll starting, locks acquired`);
this._poll = this._query();
const current = await this._poll;
debug(`Query returned data with ${current && typeof current === 'object' && 'items' in current
? current.items.length
: 'unknown'} items`);
// Save a local copy of the state to ensure consistency during this poll cycle
const previousState = this._state;
debug(`Previous state: ${previousState
? typeof previousState === 'object' && 'items' in previousState
? `has ${previousState.items.length} items`
: 'exists but has no items array'
: 'undefined'}`);
if (previousState) {
// If we have a previous state, check if it is the same as the current one
if ((0, node_util_1.isDeepStrictEqual)(previousState, current)) {
// If the state is the same, we don't need to emit an update
debug(`Current state is identical to previous state, not emitting`);
return;
}
debug(`State has changed since previous poll`);
}
else {
debug(`First poll, no previous state`);
}
// Run the predicate with our safely stored previous state
debug(`Running predicate function`);
const diff = await this._predicate(previousState, current);
debug(`Predicate returned: ${diff
? diff === true
? 'true'
: 'custom result object'
: 'false/null/undefined'}`);
// Update state FIRST, before any potential emissions
// This ensures race conditions don't cause duplicate emissions
this._state = current;
debug(`Updated state BEFORE emission processing`);
if (diff) {
// Emit the appropriate update using our safe emit method
if (diff === true) {
if (debug.enabled) {
// Check if debug is enabled before logging - this impacts performance
debug(`Safely emitting full current state with duplicate prevention`);
const itemsCount = current && typeof current === 'object' && 'items' in current
? current.items.length
: 'N/A';
debug(`Attempting to emit ${itemsCount} items`);
}
this.safeEmit(current);
}
else {
debug(`Safely emitting custom result from predicate with duplicate prevention`);
const itemsCount = diff && typeof diff === 'object' && 'items' in diff
? diff.items.length
: 'N/A';
debug(`Attempting to emit ${itemsCount} items`);
this.safeEmit(diff);
}
}
else {
debug(`No emission needed based on predicate result`);
}
}
catch (error) {
;
error.message = `QuerySubscription: ${error.message}`;
throw error;
}
finally {
// Centralized lock release - ensures locks are always released regardless of execution path
this._predicateLock = false;
this._pollLock = false;
debug(`Poll completed, locks released`);
}
}
}
//# sourceMappingURL=QuerySubscription.js.map