@getanthill/datastore
Version:
Event-Sourced Datastore
171 lines • 6.44 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const events_1 = require("events");
const eventsource_1 = __importDefault(require("eventsource"));
const merge_1 = __importDefault(require("lodash/merge"));
const amqp_1 = __importDefault(require("../services/amqp"));
const utils_1 = require("./utils");
class Streams extends events_1.EventEmitter {
constructor(config, core) {
super();
this.config = {
baseUrl: 'http://localhost:3001',
token: 'token',
debug: false,
connector: 'http',
};
this._streams = new Map();
this.config = (0, merge_1.default)({}, this.config, config);
this._core = core;
this._telemetry = this.config.telemetry;
}
getEventSource(url, headers) {
return new eventsource_1.default(url, headers);
}
getAMQPClient(options) {
return new amqp_1.default((0, merge_1.default)({}, this.config.amqp, {
queue: {
consumer: {
name: options?.queueName,
},
},
}), this._telemetry);
}
// HTTP Stream
getStreamId(model, source, query = {}) {
return `${model}:${source}:${JSON.stringify(query)}`;
}
async getStreamCloseMethod(model, source, streamId, query, options) {
let streamHandler;
if (this.config.connector === 'amqp') {
streamHandler = this.streamAMQP.bind(this);
}
else {
streamHandler = this.streamHTTP.bind(this);
}
return streamHandler((data, route, headers, opts) => this.emit(streamId, data, route, headers, opts), model, source, query, options);
}
/**
* Streaming API
* @beta
*/
async listen(model, source, query, options) {
const streamId = this.getStreamId(model, source, query);
if (this._streams.has(streamId)) {
this._telemetry?.logger.debug('[Datastore] Stream already registered');
return Promise.resolve(this._streams.get(streamId));
}
const close = await this.getStreamCloseMethod(model, source, streamId, query, options);
this._streams.set(streamId, close);
if (typeof options?.forward?.emit === 'function') {
this.on(streamId, (event, route, headers, opts) => options.forward.emit(streamId, event, route, headers, opts));
}
/* istanbul ignore next */
return () => this.close(streamId);
}
close(streamId) {
const close = this._streams.get(streamId);
typeof close === 'function' && close();
this._streams.delete(streamId);
this.removeAllListeners(streamId);
}
closeAll() {
for (const streamId of this._streams.keys()) {
this.close(streamId);
}
this._telemetry?.logger.info('[Datastore#closeAll] All streams closed', {
url: this.config.baseUrl,
});
}
async streamAMQP(handler, model, source, query = {}, options) {
this._telemetry?.logger.info('[Datastore#streamAMQP] Initialization', {
url: this.config.baseUrl,
model,
source,
query,
options,
});
const amqp = this.getAMQPClient(options);
await amqp.connect();
const modelName = model === 'all' ? '*' : model;
const topic = source === 'events'
? `${modelName}/*/events/*`
: `${modelName}/*/success/*`;
amqp.on(topic, async (event, route, headers, opts) => {
await handler(event, route, headers, opts);
});
const schema = options?.queryAsJSONSchema === true ? query : (0, utils_1.objToJsonSchema)(query);
await amqp.subscribe(topic, schema);
return () => {
amqp.end().catch((err) => {
this._telemetry?.logger.error('[Streams#streamAMQP] Error on closing', {
err,
});
});
};
}
/**
* @deprecated in favor of streamHTTP
*/
stream(...args) {
/* @ts-ignore */
return this.streamHTTP.call(this, ...args);
}
/**
* Stream route to listen for specific documents update from the
* Datastore
*/
/* istanbul ignore next */
async streamHTTP(handler, model = 'all', source = 'entities', query) {
return new Promise((resolve) => {
const projections = Array.isArray(query) ? query : [];
if (query && !Array.isArray(query) && Object.keys(query).length > 0) {
const match = {};
for (const key of Object.keys(query)) {
match[`fullDocument.${key}`] = query[key];
}
projections.push({ $match: match });
}
const evtSource = this.getEventSource(this.config.baseUrl +
this._core.getPath('stream', model, source, 'sse') +
'?pipeline=' +
JSON.stringify(projections), {
withCredentials: true,
headers: {
authorization: this.config.token,
},
});
evtSource.addEventListener('message', async function (event) {
if (!event.data) {
return;
}
await handler(JSON.parse(event.data));
});
evtSource.addEventListener('error', (err) => this._telemetry?.logger.warn('[Datastore#streamHTTP] Stream error', {
url: this.config.baseUrl,
model,
source,
err,
}));
const _close = () => {
evtSource.close();
};
/**
* The event is emitted on first line reception. This is
* possible thanks to the keep alive message
* (#serverSentEventKeepAlive: :\n\n)
*/
evtSource.addEventListener('open', (e) => {
resolve(_close);
});
});
}
}
Streams.ERRORS = {
ERROR_STREAM_MAX_RECONNECTION_ATTEMPTS_REACHED: new Error('Max reconnection attempts reached for streaming'),
};
exports.default = Streams;
//# sourceMappingURL=Streams.js.map