@yarkivaev/source-to-sink 1.3.3 → 1.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/source-to-sink",
3
- "version": "1.3.3",
3
+ "version": "1.3.5",
4
4
  "description": "Generic library for building data streaming pipelines",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,30 +1,5 @@
1
1
  import pg from 'pg';
2
2
 
3
- /**
4
- * PostgreSQL sink for batch record insertion.
5
- *
6
- * Creates a pg.Pool internally and implements the Sink
7
- * interface for use with batch collectors. Supports optional
8
- * conflict resolution via ON CONFLICT DO NOTHING or
9
- * ON CONFLICT DO UPDATE SET.
10
- *
11
- * @example
12
- * const sink = postgresSink('postgresql://localhost:5432/db', 'metrics', ['ts', 'value']);
13
- * await sink.write([{ ts: Date.now(), value: 42 }]);
14
- *
15
- * @example
16
- * const sink = postgresSink('postgresql://localhost:5432/db', 'segments',
17
- * ['machine', 'name', 'start_time'], { conflict: ['machine', 'start_time'] });
18
- * await sink.write([{ machine: 'a', name: 'on', start_time: '2024-01-01' }]);
19
- *
20
- * @param {string} url - PostgreSQL URL (e.g., 'postgresql://localhost:5432/db')
21
- * @param {string} table - Target table name
22
- * @param {Array<string>} columns - Column names for insertion
23
- * @param {object} [options] - Optional configuration
24
- * @param {Array<string>} [options.conflict] - Columns for ON CONFLICT clause
25
- * @param {Array<string>} [options.update] - Columns for DO UPDATE SET clause
26
- * @returns {object} Sink with write(records) method
27
- */
28
3
  /**
29
4
  * Removes duplicate records by conflict key within a batch.
30
5
  *
@@ -70,6 +45,31 @@ function buildSuffix(options) {
70
45
  return ` ON CONFLICT (${cols}) DO NOTHING`;
71
46
  }
72
47
 
48
+ /**
49
+ * PostgreSQL sink for batch record insertion.
50
+ *
51
+ * Creates a pg.Pool internally unless `options.pool` is supplied.
52
+ * Supports optional conflict resolution via ON CONFLICT DO NOTHING or
53
+ * ON CONFLICT DO UPDATE SET. `write` accepts an optional queryable
54
+ * client so callers can run inserts inside an existing transaction.
55
+ *
56
+ * @example
57
+ * const sink = postgresSink('postgresql://localhost:5432/db', 'metrics', ['ts', 'value']);
58
+ * await sink.write([{ ts: Date.now(), value: 42 }]);
59
+ *
60
+ * @example
61
+ * const sink = postgresSink(url, 'segments', cols, { pool, conflict: ['machine', 'start_time'] });
62
+ * await sink.write([{ machine: 'a', name: 'on', start_time: '2024-01-01' }], client);
63
+ *
64
+ * @param {string} url - PostgreSQL URL (e.g., 'postgresql://localhost:5432/db')
65
+ * @param {string} table - Target table name
66
+ * @param {Array<string>} columns - Column names for insertion
67
+ * @param {object} [options] - Optional configuration
68
+ * @param {Array<string>} [options.conflict] - Columns for ON CONFLICT clause
69
+ * @param {Array<string>} [options.update] - Columns for DO UPDATE SET clause
70
+ * @param {object} [options.pool] - Existing pg Pool to reuse
71
+ * @returns {object} Sink with write(records, client?) method
72
+ */
73
73
  export default function postgresSink(url, table, columns, options = {}) {
74
74
  if (typeof url !== 'string' || url.length === 0) {
75
75
  throw new Error('URL must be a non-empty string');
@@ -80,7 +80,7 @@ export default function postgresSink(url, table, columns, options = {}) {
80
80
  if (!Array.isArray(columns) || columns.length === 0) {
81
81
  throw new Error('Columns must be a non-empty array');
82
82
  }
83
- const pool = new pg.Pool({ connectionString: url });
83
+ const pool = options.pool || new pg.Pool({ connectionString: url });
84
84
  const suffix = buildSuffix(options);
85
85
  const conflict = Array.isArray(options.conflict) ? options.conflict : [];
86
86
  return {
@@ -88,9 +88,10 @@ export default function postgresSink(url, table, columns, options = {}) {
88
88
  * Writes records to PostgreSQL table.
89
89
  *
90
90
  * @param {Array} records - Array of objects with keys matching columns
91
+ * @param {object} [client] - Optional pg client/pool with query()
91
92
  * @returns {Promise} Promise resolving when insert completes
92
93
  */
93
- write(records) {
94
+ write(records, client) {
94
95
  const unique = deduplicate(records, conflict);
95
96
  const placeholders = unique.map((record, i) => {
96
97
  const offset = i * columns.length;
@@ -99,7 +100,8 @@ export default function postgresSink(url, table, columns, options = {}) {
99
100
  }).join(', ');
100
101
  const query = `INSERT INTO ${table} (${columns.join(', ')}) VALUES ${placeholders}${suffix}`;
101
102
  const values = unique.flatMap((record) => {return columns.map((col) => {return record[col]})});
102
- return pool.query(query, values);
103
+ const runner = client && typeof client.query === 'function' ? client : pool;
104
+ return runner.query(query, values);
103
105
  }
104
106
  };
105
107
  }
package/src/stompSend.js CHANGED
@@ -7,6 +7,20 @@ import stompit from 'stompit';
7
7
  * @param {object} options - Connection options
8
8
  * @returns {object} stompit connect parameters
9
9
  */
10
+ /**
11
+ * STOMP SEND headers that persist the body on a durable RabbitMQ queue.
12
+ *
13
+ * @param {string} destination - STOMP destination
14
+ * @returns {object} headers including persistent true
15
+ */
16
+ export function stompHeaders(destination) {
17
+ return {
18
+ destination,
19
+ 'content-type': 'application/json',
20
+ persistent: 'true'
21
+ };
22
+ }
23
+
10
24
  function connectParams(parsed, options) {
11
25
  return {
12
26
  host: parsed.hostname,
@@ -55,10 +69,7 @@ export default function stompSend(url, destination, payload, options = {}) {
55
69
  reject(error);
56
70
  return;
57
71
  }
58
- const frame = client.send({
59
- destination,
60
- 'content-type': 'application/json'
61
- });
72
+ const frame = client.send(stompHeaders(destination));
62
73
  frame.end(body);
63
74
  client.disconnect((disconnectError) => {
64
75
  if (disconnectError) {
@@ -30,30 +30,6 @@ function subscribed(channel) {
30
30
  };
31
31
  }
32
32
 
33
- /**
34
- * STOMP subscription source for streaming messages to a collector.
35
- *
36
- * Subscribes to a STOMP destination and forwards raw messages to the collector.
37
- * Messages are passed as {destination, payload} objects without parsing.
38
- * Uses ConnectFailover for automatic reconnection with exponential backoff
39
- * and Channel for transparent re-subscription. Acknowledges messages only
40
- * after the collector has accepted them.
41
- *
42
- * @example
43
- * const source = stompSource('stomp://localhost:61613', '/queue/sensors', collector);
44
- * source.start();
45
- * // ... later
46
- * source.stop();
47
- *
48
- * @param {string} url - STOMP broker URL (e.g., 'stomp://localhost:61613')
49
- * @param {string} destination - STOMP destination to subscribe (e.g., '/queue/sensors')
50
- * @param {object} collector - Collector with accept() method receiving {destination, payload}
51
- * @param {object} [options] - Optional STOMP connection options
52
- * @param {string} [options.login] - Login for STOMP auth
53
- * @param {string} [options.passcode] - Passcode for STOMP auth
54
- * @param {string} [options.host] - Virtual host for STOMP connection
55
- * @returns {object} Source with start() and stop() methods
56
- */
57
33
  /**
58
34
  * Builds STOMP server configuration from a URL.
59
35
  *
@@ -73,6 +49,98 @@ function serverConfig(parsed, options) {
73
49
  };
74
50
  }
75
51
 
52
+ /**
53
+ * Builds settle helpers for one STOMP frame.
54
+ *
55
+ * @param {object} channel - STOMP channel
56
+ * @param {object} message - STOMP frame
57
+ * @returns {{ ack: Function, nack: Function }}
58
+ */
59
+ function settleOf(channel, message) {
60
+ return {
61
+ ack() {
62
+ channel.ack(message);
63
+ },
64
+ nack() {
65
+ channel.nack(message);
66
+ }
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Delivers one STOMP body to the collector and settles the frame.
72
+ *
73
+ * @param {object} ctx - delivery context with channel/message/destination/collector/manualAck
74
+ * @param {string} body - UTF-8 payload
75
+ * @returns {Promise<void>}
76
+ */
77
+ async function deliver(ctx, body) {
78
+ const settle = settleOf(ctx.channel, ctx.message);
79
+ try {
80
+ await ctx.collector.accept({ destination: ctx.destination, payload: body, settle });
81
+ if (!ctx.manualAck) {
82
+ settle.ack();
83
+ }
84
+ } catch (error) {
85
+ if (!ctx.manualAck) {
86
+ settle.nack();
87
+ }
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Ignores a rejected delivery promise when running non-serial.
94
+ *
95
+ * @param {Error} error - rejected reason
96
+ * @returns {undefined}
97
+ */
98
+ function ignoreDelivery(error) {
99
+ return error;
100
+ }
101
+
102
+ /**
103
+ * Subscribes a channel and routes frames to the collector.
104
+ *
105
+ * @param {object} channel - STOMP channel
106
+ * @param {string} destination - queue destination
107
+ * @param {object} collector - message collector
108
+ * @param {boolean} serial - global serial delivery
109
+ * @param {boolean} manualAck - collector settles frames
110
+ * @returns {undefined}
111
+ */
112
+ function bind(channel, destination, collector, serial, manualAck) {
113
+ let pending = Promise.resolve();
114
+ channel.subscribe({ destination, ack: 'client-individual' }, (err, message) => {
115
+ if (err) { return; }
116
+ message.readString('utf-8', (readErr, body) => {
117
+ if (readErr) { return; }
118
+ const ctx = { channel, message, destination, collector, manualAck };
119
+ function work() {
120
+ return deliver(ctx, body.toString());
121
+ }
122
+ if (serial) {
123
+ pending = pending.then(work, work);
124
+ } else {
125
+ void work().catch(ignoreDelivery);
126
+ }
127
+ });
128
+ });
129
+ }
130
+
131
+ /**
132
+ * STOMP subscription source for streaming messages to a collector.
133
+ *
134
+ * Messages are `{destination, payload, settle}`. Default ack-after-accept;
135
+ * with `manualAck` the collector owns settle. With `serial: false`
136
+ * deliveries are not globally sequenced.
137
+ *
138
+ * @param {string} url - STOMP broker URL
139
+ * @param {string} destination - STOMP destination
140
+ * @param {object} collector - Collector with accept()
141
+ * @param {object} [options] - Connection and delivery options
142
+ * @returns {object} Source with start() and stop() methods
143
+ */
76
144
  export default function stompSource(url, destination, collector, options = {}) {
77
145
  if (typeof url !== 'string' || url.length === 0) {
78
146
  throw new Error('URL must be a non-empty string');
@@ -84,11 +152,10 @@ export default function stompSource(url, destination, collector, options = {}) {
84
152
  throw new Error('Collector must have an accept() method');
85
153
  }
86
154
  const parsed = new URL(url);
155
+ const serial = options.serial !== false;
156
+ const manualAck = options.manualAck === true;
87
157
  let state = idle();
88
158
  return {
89
- /**
90
- * Starts subscribing to the STOMP destination.
91
- */
92
159
  start() {
93
160
  if (state.subscribed()) {
94
161
  return;
@@ -99,25 +166,9 @@ export default function stompSource(url, destination, collector, options = {}) {
99
166
  maxReconnects: -1
100
167
  });
101
168
  const channel = new stompit.Channel(failover);
102
- let pending = Promise.resolve();
103
- const subscribe = { destination, ack: 'client-individual' };
104
- channel.subscribe(subscribe, (err, message) => {
105
- if (err) { return; }
106
- message.readString('utf-8', (readErr, body) => {
107
- if (readErr) { return; }
108
- pending = pending.then(async () => {
109
- try {
110
- await collector.accept({ destination, payload: body.toString() });
111
- channel.ack(message);
112
- } catch { channel.nack(message); }
113
- });
114
- });
115
- });
169
+ bind(channel, destination, collector, serial, manualAck);
116
170
  state = subscribed(channel);
117
171
  },
118
- /**
119
- * Stops subscribing to the STOMP destination.
120
- */
121
172
  stop() {
122
173
  if (!state.subscribed()) {
123
174
  return;
@@ -108,4 +108,31 @@ describe('postgresSink', () => {
108
108
  const result = deduplicate(records, []);
109
109
  assert.strictEqual(result.length, 2, 'Should not deduplicate without conflict columns');
110
110
  });
111
+
112
+ it('reuses an injected pool when writing', async () => {
113
+ const queries = [];
114
+ const pool = {
115
+ query(sql, values) {
116
+ queries.push({ sql, values });
117
+ return Promise.resolve({ rowCount: 1 });
118
+ }
119
+ };
120
+ const sink = postgresSink('postgresql://localhost:5432/db', 'metrics', ['ts', 'value'], { pool });
121
+ await sink.write([{ ts: 1700000000, value: 42 }]);
122
+ assert.strictEqual(queries.length, 1, 'injected pool was not used for write');
123
+ });
124
+
125
+ it('routes write through an explicit client when provided', async () => {
126
+ const pool = { query() { return Promise.reject(new Error('pool must not be used')); } };
127
+ const clientQueries = [];
128
+ const client = {
129
+ query(sql, values) {
130
+ clientQueries.push({ sql, values });
131
+ return Promise.resolve({ rowCount: 1 });
132
+ }
133
+ };
134
+ const sink = postgresSink('postgresql://localhost:5432/db', 'metrics', ['ts', 'value'], { pool });
135
+ await sink.write([{ ts: 1700000001, value: 7 }], client);
136
+ assert.strictEqual(clientQueries.length, 1, 'explicit client was not used for write');
137
+ });
111
138
  });
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert';
2
2
  import { describe, it } from 'mocha';
3
- import stompSend from '../src/stompSend.js';
3
+ import stompSend, { stompHeaders } from '../src/stompSend.js';
4
4
 
5
5
  describe('stompSend', () => {
6
6
  it('throws on missing url', () => {
@@ -23,6 +23,15 @@ describe('stompSend', () => {
23
23
  );
24
24
  });
25
25
 
26
+ it('marks SEND frames persistent', () => {
27
+ const destination = `/exchange/\u00e9${Math.random().toString(36).slice(2)}`;
28
+ assert.strictEqual(
29
+ stompHeaders(destination).persistent,
30
+ 'true',
31
+ 'stompSend did not mark the frame persistent'
32
+ );
33
+ });
34
+
26
35
  it('returns a promise', () => {
27
36
  const pending = stompSend(
28
37
  `stomp://\u00e9${Math.random()}:61613`,
@@ -51,4 +51,13 @@ describe('stompSource', () => {
51
51
  assert.strictEqual(typeof source.start, 'function', 'Should have start method');
52
52
  assert.strictEqual(typeof source.stop, 'function', 'Should have stop method');
53
53
  });
54
+
55
+ it('accepts manualAck and serial options without throwing', () => {
56
+ const collector = { accept: () => {} };
57
+ const source = stompSource('stomp://localhost:61613', '/queue/test', collector, {
58
+ manualAck: true,
59
+ serial: false
60
+ });
61
+ assert.strictEqual(typeof source.start, 'function', 'manualAck source missing start');
62
+ });
54
63
  });