@yarkivaev/source-to-sink 1.3.4 → 1.3.6

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.4",
3
+ "version": "1.3.6",
4
4
  "description": "Generic library for building data streaming pipelines",
5
5
  "repository": {
6
6
  "type": "git",
package/src/follow.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Continues a delivery chain after a rejected prior step.
3
+ *
4
+ * @param {Promise} pending - prior chain
5
+ * @returns {Promise} settled chain
6
+ */
7
+ function resume(pending) {
8
+ return pending.then((value) => {
9
+ return value;
10
+ }, (error) => {
11
+ return error;
12
+ });
13
+ }
14
+
15
+ /**
16
+ * Chains one arrival: read the body, then start deliver.
17
+ * Serial waits for deliver. Otherwise persist may overlap after enqueue.
18
+ *
19
+ * @param {Promise} pending - prior arrival
20
+ * @param {Function} read - returns a promise for the body
21
+ * @param {Function} deliver - starts persist for one body
22
+ * @param {boolean} serial - wait for persist before the next arrival
23
+ * @returns {Promise} chain
24
+ *
25
+ * @example
26
+ * let pending = Promise.resolve();
27
+ * pending = follow(pending, () => Promise.resolve('a'), write, false);
28
+ */
29
+ export default function follow(pending, read, deliver, serial) {
30
+ return resume(pending).then(async () => {
31
+ const body = await read();
32
+ const run = deliver(body);
33
+ if (serial) {
34
+ return run;
35
+ }
36
+ void run.catch((error) => {
37
+ return error;
38
+ });
39
+ return undefined;
40
+ });
41
+ }
@@ -32,7 +32,7 @@ export function deduplicate(records, keys) {
32
32
  /**
33
33
  * Builds the ON CONFLICT SQL suffix from options.
34
34
  *
35
- * @param {object} options - Sink options with conflict and update arrays
35
+ * @param {object} options - Sink options with conflict, update, and optional updateWhere
36
36
  * @returns {string} SQL suffix or empty string
37
37
  */
38
38
  function buildSuffix(options) {
@@ -40,7 +40,10 @@ function buildSuffix(options) {
40
40
  const cols = options.conflict.join(', ');
41
41
  if (Array.isArray(options.update) && options.update.length > 0) {
42
42
  const sets = options.update.map((col) => {return `${col} = EXCLUDED.${col}`}).join(', ');
43
- return ` ON CONFLICT (${cols}) DO UPDATE SET ${sets}`;
43
+ const guard = typeof options.updateWhere === 'string' && options.updateWhere.length > 0
44
+ ? ` WHERE ${options.updateWhere}`
45
+ : '';
46
+ return ` ON CONFLICT (${cols}) DO UPDATE SET ${sets}${guard}`;
44
47
  }
45
48
  return ` ON CONFLICT (${cols}) DO NOTHING`;
46
49
  }
@@ -67,6 +70,7 @@ function buildSuffix(options) {
67
70
  * @param {object} [options] - Optional configuration
68
71
  * @param {Array<string>} [options.conflict] - Columns for ON CONFLICT clause
69
72
  * @param {Array<string>} [options.update] - Columns for DO UPDATE SET clause
73
+ * @param {string} [options.updateWhere] - Optional WHERE guard on DO UPDATE
70
74
  * @param {object} [options.pool] - Existing pg Pool to reuse
71
75
  * @returns {object} Sink with write(records, client?) method
72
76
  */
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) {
@@ -1,4 +1,5 @@
1
1
  import stompit from 'stompit';
2
+ import follow from './follow.js';
2
3
 
3
4
  /**
4
5
  * Idle state for STOMP source.
@@ -90,41 +91,46 @@ async function deliver(ctx, body) {
90
91
  }
91
92
 
92
93
  /**
93
- * Ignores a rejected delivery promise when running non-serial.
94
+ * Reads one STOMP frame body as text.
94
95
  *
95
- * @param {Error} error - rejected reason
96
- * @returns {undefined}
96
+ * @param {object} message - STOMP frame
97
+ * @returns {Promise<string>} UTF-8 body
97
98
  */
98
- function ignoreDelivery(error) {
99
- return error;
99
+ function read(message) {
100
+ return new Promise((resolve, reject) => {
101
+ message.readString('utf-8', (error, body) => {
102
+ if (error) {
103
+ reject(error);
104
+ return;
105
+ }
106
+ resolve(body.toString());
107
+ });
108
+ });
100
109
  }
101
110
 
102
111
  /**
103
112
  * Subscribes a channel and routes frames to the collector.
113
+ * Arrivals stay in subscribe order through follow, even when persist overlaps.
104
114
  *
105
115
  * @param {object} channel - STOMP channel
106
116
  * @param {string} destination - queue destination
107
117
  * @param {object} collector - message collector
108
- * @param {boolean} serial - global serial delivery
118
+ * @param {boolean} serial - wait for persist before the next arrival
109
119
  * @param {boolean} manualAck - collector settles frames
110
120
  * @returns {undefined}
111
121
  */
112
122
  function bind(channel, destination, collector, serial, manualAck) {
113
123
  let pending = Promise.resolve();
114
124
  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
- });
125
+ if (err) {
126
+ return;
127
+ }
128
+ const ctx = { channel, message, destination, collector, manualAck };
129
+ pending = follow(pending, () => {
130
+ return read(message);
131
+ }, (body) => {
132
+ return deliver(ctx, body);
133
+ }, serial);
128
134
  });
129
135
  }
130
136
 
@@ -132,8 +138,8 @@ function bind(channel, destination, collector, serial, manualAck) {
132
138
  * STOMP subscription source for streaming messages to a collector.
133
139
  *
134
140
  * 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.
141
+ * with `manualAck` the collector owns settle. Arrivals enqueue in
142
+ * subscribe order. With `serial: false` persist may overlap after enqueue.
137
143
  *
138
144
  * @param {string} url - STOMP broker URL
139
145
  * @param {string} destination - STOMP destination
@@ -0,0 +1,35 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'mocha';
3
+ import follow from '../src/follow.js';
4
+
5
+ describe('follow', () => {
6
+ it('accepts in subscribe order when the first body is slower', async () => {
7
+ const order = [];
8
+ const first = `ä-${Math.random().toString(16).slice(2)}`;
9
+ const second = `ö-${Math.random().toString(16).slice(2)}`;
10
+ const pause = (ms) => {
11
+ return new Promise((resolve) => {
12
+ setTimeout(resolve, ms);
13
+ });
14
+ };
15
+ const deliver = (body) => {
16
+ order.push(body);
17
+ return Promise.resolve();
18
+ };
19
+ let pending = Promise.resolve();
20
+ pending = follow(pending, () => {
21
+ return pause(40).then(() => {
22
+ return first;
23
+ });
24
+ }, deliver, false);
25
+ pending = follow(pending, () => {
26
+ return Promise.resolve(second);
27
+ }, deliver, false);
28
+ await pending;
29
+ assert.deepStrictEqual(
30
+ order,
31
+ [first, second],
32
+ 'a slower first body reordered accept ahead of subscribe'
33
+ );
34
+ });
35
+ });
@@ -122,6 +122,28 @@ describe('postgresSink', () => {
122
122
  assert.strictEqual(queries.length, 1, 'injected pool was not used for write');
123
123
  });
124
124
 
125
+ it('appends updateWhere to the conflict update', async () => {
126
+ const queries = [];
127
+ const pool = {
128
+ query(sql) {
129
+ queries.push(sql);
130
+ return Promise.resolve({ rowCount: 1 });
131
+ }
132
+ };
133
+ const sink = postgresSink('postgresql://localhost:5432/db', 'segments',
134
+ ['machine', 'duration'], {
135
+ pool,
136
+ conflict: ['machine'],
137
+ update: ['duration'],
138
+ updateWhere: 'segments.duration = 0 OR EXCLUDED.duration > 0'
139
+ });
140
+ await sink.write([{ machine: `ä-${Math.random()}`, duration: 0 }]);
141
+ assert.ok(
142
+ queries[0].includes('WHERE segments.duration = 0 OR EXCLUDED.duration > 0'),
143
+ 'conflict update did not keep the monotonic duration guard'
144
+ );
145
+ });
146
+
125
147
  it('routes write through an explicit client when provided', async () => {
126
148
  const pool = { query() { return Promise.reject(new Error('pool must not be used')); } };
127
149
  const clientQueries = [];
@@ -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`,