@yarkivaev/source-to-sink 1.3.5 → 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 +1 -1
- package/src/follow.js +41 -0
- package/src/postgresSink.js +6 -2
- package/src/stompSource.js +27 -21
- package/test/test_follow.js +35 -0
- package/test/test_postgresSink.js +22 -0
package/package.json
CHANGED
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
|
+
}
|
package/src/postgresSink.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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/stompSource.js
CHANGED
|
@@ -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
|
-
*
|
|
94
|
+
* Reads one STOMP frame body as text.
|
|
94
95
|
*
|
|
95
|
-
* @param {
|
|
96
|
-
* @returns {
|
|
96
|
+
* @param {object} message - STOMP frame
|
|
97
|
+
* @returns {Promise<string>} UTF-8 body
|
|
97
98
|
*/
|
|
98
|
-
function
|
|
99
|
-
return
|
|
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 -
|
|
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) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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.
|
|
136
|
-
*
|
|
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 = [];
|