', {
53 | 'href': res.url,
54 | 'text': res.title
55 | });
56 |
57 | var content = res.body.trim();
58 | if (content.length > MAX_DESCRIPTION_SIZE) {
59 | content = content.slice(0, MAX_DESCRIPTION_SIZE).trim()+'...';
60 | }
61 | var $content = $('').html(content);
62 |
63 | $link.appendTo($title);
64 | $title.appendTo($li);
65 | $content.appendTo($li);
66 | $li.appendTo($searchList);
67 | });
68 | }
69 |
70 | function launchSearch(q) {
71 | $body.addClass('with-search');
72 |
73 | if ($xsMenu.css('display') === 'block') {
74 | $mainContainer.css('height', 'calc(100% - 100px)');
75 | $mainContainer.css('margin-top', '100px');
76 | }
77 |
78 | throttle(compodoc.search.query(q, 0, MAX_RESULTS)
79 | .then(function(results) {
80 | displayResults(results);
81 | }), 1000);
82 | }
83 |
84 | function closeSearch() {
85 | $body.removeClass('with-search');
86 | if ($xsMenu.css('display') === 'block') {
87 | $mainContainer.css('height', 'calc(100% - 50px)');
88 | $mainContainer.css('margin-top', '50px');
89 | }
90 | }
91 |
92 | function bindMenuButton() {
93 | document.getElementById('btn-menu').addEventListener('click', function() {
94 | if ($xsMenu.css('display') === 'none') {
95 | $body.removeClass('with-search');
96 | $mainContainer.css('height', 'calc(100% - 50px)');
97 | $mainContainer.css('margin-top', '50px');
98 | }
99 | $.each($searchInputs, function(index, item){
100 | var item = $(item);
101 | item.val('');
102 | });
103 | });
104 | }
105 |
106 | function bindSearch() {
107 | // Bind DOM
108 | $searchInputs = $('#book-search-input input');
109 |
110 | $searchResults = $('.search-results');
111 | $searchList = $searchResults.find('.search-results-list');
112 | $searchTitle = $searchResults.find('.search-results-title');
113 | $searchResultsCount = $searchTitle.find('.search-results-count');
114 | $searchQuery = $searchTitle.find('.search-query');
115 | $mainContainer = $('.container-fluid');
116 | $xsMenu = $('.xs-menu');
117 |
118 | // Launch query based on input content
119 | function handleUpdate(item) {
120 | var q = item.val();
121 |
122 | if (q.length == 0) {
123 | closeSearch();
124 | } else {
125 | launchSearch(q);
126 | }
127 | }
128 |
129 | // Detect true content change in search input
130 | var propertyChangeUnbound = false;
131 |
132 | $.each($searchInputs, function(index, item){
133 | var item = $(item);
134 | // HTML5 (IE9 & others)
135 | item.on('input', function(e) {
136 | // Unbind propertychange event for IE9+
137 | if (!propertyChangeUnbound) {
138 | $(this).unbind('propertychange');
139 | propertyChangeUnbound = true;
140 | }
141 |
142 | handleUpdate($(this));
143 | });
144 | // Workaround for IE < 9
145 | item.on('propertychange', function(e) {
146 | if (e.originalEvent.propertyName == 'value') {
147 | handleUpdate($(this));
148 | }
149 | });
150 | // Push to history on blur
151 | item.on('blur', function(e) {
152 | // Update history state
153 | if (usePushState) {
154 | var uri = updateQueryString('q', $(this).val());
155 | history.pushState({ path: uri }, null, uri);
156 | }
157 | });
158 | });
159 | }
160 |
161 | function launchSearchFromQueryString() {
162 | var q = getParameterByName('q');
163 | if (q && q.length > 0) {
164 | // Update search inputs
165 | $.each($searchInputs, function(index, item){
166 | var item = $(item);
167 | item.val(q)
168 | });
169 | // Launch search
170 | launchSearch(q);
171 | }
172 | }
173 |
174 | compodoc.addEventListener(compodoc.EVENTS.SEARCH_READY, function(event) {
175 | bindSearch();
176 |
177 | bindMenuButton();
178 |
179 | launchSearchFromQueryString();
180 | });
181 |
182 | function getParameterByName(name) {
183 | var url = window.location.href;
184 | name = name.replace(/[\[\]]/g, '\\$&');
185 | var regex = new RegExp('[?&]' + name + '(=([^]*)|&|#|$)', 'i'),
186 | results = regex.exec(url);
187 | if (!results) return null;
188 | if (!results[2]) return '';
189 | return decodeURIComponent(results[2].replace(/\+/g, ' '));
190 | }
191 |
192 | function updateQueryString(key, value) {
193 | value = encodeURIComponent(value);
194 |
195 | var url = window.location.href;
196 | var re = new RegExp('([?&])' + key + '=.*?(&|#|$)(.*)', 'gi'),
197 | hash;
198 |
199 | if (re.test(url)) {
200 | if (typeof value !== 'undefined' && value !== null)
201 | return url.replace(re, '$1' + key + '=' + value + '$2$3');
202 | else {
203 | hash = url.split('#');
204 | url = hash[0].replace(re, '$1$3').replace(/(&|\?)$/, '');
205 | if (typeof hash[1] !== 'undefined' && hash[1] !== null)
206 | url += '#' + hash[1];
207 | return url;
208 | }
209 | }
210 | else {
211 | if (typeof value !== 'undefined' && value !== null) {
212 | var separator = url.indexOf('?') !== -1 ? '&' : '?';
213 | hash = url.split('#');
214 | url = hash[0] + separator + key + '=' + value;
215 | if (typeof hash[1] !== 'undefined' && hash[1] !== null)
216 | url += '#' + hash[1];
217 | return url;
218 | }
219 | else
220 | return url;
221 | }
222 | }
223 | })(compodoc);
224 |
--------------------------------------------------------------------------------
/documentation/js/svg-pan-zoom.controls.js:
--------------------------------------------------------------------------------
1 | document.getElementById('demo-svg').addEventListener('load', function() {
2 | panZoom = svgPanZoom('#demo-svg', {
3 | zoomEnabled: true,
4 | minZoom: 1,
5 | maxZoom: 5
6 | });
7 |
8 | document.getElementById('zoom-in').addEventListener('click', function(ev) {
9 | ev.preventDefault()
10 | panZoom.zoomIn()
11 | });
12 |
13 | document.getElementById('zoom-out').addEventListener('click', function(ev) {
14 | ev.preventDefault()
15 | panZoom.zoomOut()
16 | });
17 |
18 | document.getElementById('reset').addEventListener('click', function(ev) {
19 | ev.preventDefault()
20 | panZoom.resetZoom();
21 | panZoom.resetPan();
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/documentation/styles/bootstrap-card.css:
--------------------------------------------------------------------------------
1 | .card {
2 | position: relative;
3 | display: block;
4 | margin-bottom: 20px;
5 | background-color: #fff;
6 | border: 1px solid #ddd;
7 | border-radius: 4px;
8 | }
9 |
10 | .card-block {
11 | padding: 15px;
12 | }
13 | .card-block:before, .card-block:after {
14 | content: " ";
15 | display: table;
16 | }
17 | .card-block:after {
18 | clear: both;
19 | }
20 |
21 | .card-title {
22 | margin: 5px;
23 | margin-bottom: 2px;
24 | text-align: center;
25 | }
26 |
27 | .card-subtitle {
28 | margin-top: -10px;
29 | margin-bottom: 0;
30 | }
31 |
32 | .card-text:last-child {
33 | margin-bottom: 0;
34 | margin-top: 10px;
35 | }
36 |
37 | .card-link:hover {
38 | text-decoration: none;
39 | }
40 | .card-link + .card-link {
41 | margin-left: 15px;
42 | }
43 |
44 | .card > .list-group:first-child .list-group-item:first-child {
45 | border-top-right-radius: 4px;
46 | border-top-left-radius: 4px;
47 | }
48 | .card > .list-group:last-child .list-group-item:last-child {
49 | border-bottom-right-radius: 4px;
50 | border-bottom-left-radius: 4px;
51 | }
52 |
53 | .card-header {
54 | padding: 10px 15px;
55 | background-color: #f5f5f5;
56 | border-bottom: 1px solid #ddd;
57 | }
58 | .card-header:before, .card-header:after {
59 | content: " ";
60 | display: table;
61 | }
62 | .card-header:after {
63 | clear: both;
64 | }
65 | .card-header:first-child {
66 | border-radius: 4px 4px 0 0;
67 | }
68 |
69 | .card-footer {
70 | padding: 10px 15px;
71 | background-color: #f5f5f5;
72 | border-top: 1px solid #ddd;
73 | }
74 | .card-footer:before, .card-footer:after {
75 | content: " ";
76 | display: table;
77 | }
78 | .card-footer:after {
79 | clear: both;
80 | }
81 | .card-footer:last-child {
82 | border-radius: 0 0 4px 4px;
83 | }
84 |
85 | .card-header-tabs {
86 | margin-right: -5px;
87 | margin-bottom: -10px;
88 | margin-left: -5px;
89 | border-bottom: 0;
90 | }
91 |
92 | .card-header-pills {
93 | margin-right: -5px;
94 | margin-left: -5px;
95 | }
96 |
97 | .card-primary {
98 | background-color: #337ab7;
99 | border-color: #337ab7;
100 | }
101 | .card-primary .card-header,
102 | .card-primary .card-footer {
103 | background-color: transparent;
104 | }
105 |
106 | .card-success {
107 | background-color: #5cb85c;
108 | border-color: #5cb85c;
109 | }
110 | .card-success .card-header,
111 | .card-success .card-footer {
112 | background-color: transparent;
113 | }
114 |
115 | .card-info {
116 | background-color: #5bc0de;
117 | border-color: #5bc0de;
118 | }
119 | .card-info .card-header,
120 | .card-info .card-footer {
121 | background-color: transparent;
122 | }
123 |
124 | .card-warning {
125 | background-color: #f0ad4e;
126 | border-color: #f0ad4e;
127 | }
128 | .card-warning .card-header,
129 | .card-warning .card-footer {
130 | background-color: transparent;
131 | }
132 |
133 | .card-danger {
134 | background-color: #d9534f;
135 | border-color: #d9534f;
136 | }
137 | .card-danger .card-header,
138 | .card-danger .card-footer {
139 | background-color: transparent;
140 | }
141 |
142 | .card-outline-primary {
143 | background-color: transparent;
144 | border-color: #337ab7;
145 | }
146 |
147 | .card-outline-secondary {
148 | background-color: transparent;
149 | border-color: #ccc;
150 | }
151 |
152 | .card-outline-info {
153 | background-color: transparent;
154 | border-color: #5bc0de;
155 | }
156 |
157 | .card-outline-success {
158 | background-color: transparent;
159 | border-color: #5cb85c;
160 | }
161 |
162 | .card-outline-warning {
163 | background-color: transparent;
164 | border-color: #f0ad4e;
165 | }
166 |
167 | .card-outline-danger {
168 | background-color: transparent;
169 | border-color: #d9534f;
170 | }
171 |
172 | .card-inverse .card-header,
173 | .card-inverse .card-footer {
174 | border-color: rgba(255, 255, 255, 0.2);
175 | }
176 | .card-inverse .card-header,
177 | .card-inverse .card-footer,
178 | .card-inverse .card-title,
179 | .card-inverse .card-blockquote {
180 | color: #fff;
181 | }
182 | .card-inverse .card-link,
183 | .card-inverse .card-text,
184 | .card-inverse .card-subtitle,
185 | .card-inverse .card-blockquote .blockquote-footer {
186 | color: rgba(255, 255, 255, 0.65);
187 | }
188 | .card-inverse .card-link:hover, .card-inverse .card-link:focus {
189 | color: #fff;
190 | }
191 |
192 | .card-blockquote {
193 | padding: 0;
194 | margin-bottom: 0;
195 | border-left: 0;
196 | }
197 |
198 | .card-img {
199 | border-radius: .25em;
200 | }
201 |
202 | .card-img-overlay {
203 | position: absolute;
204 | top: 0;
205 | right: 0;
206 | bottom: 0;
207 | left: 0;
208 | padding: 15px;
209 | }
210 |
211 | .card-img-top {
212 | border-top-right-radius: 4px;
213 | border-top-left-radius: 4px;
214 | }
215 |
216 | .card-img-bottom {
217 | border-bottom-right-radius: 4px;
218 | border-bottom-left-radius: 4px;
219 | }
220 |
--------------------------------------------------------------------------------
/documentation/styles/compodoc.css:
--------------------------------------------------------------------------------
1 | body {
2 | position: absolute;
3 | width: 100%;
4 | height: 100%;
5 | font-family: 'Roboto', sans-serif;
6 | }
7 |
8 | /* roboto-300 - latin */
9 | @font-face {
10 | font-family: 'Roboto';
11 | font-style: normal;
12 | font-weight: 300;
13 | src: url('../fonts/roboto-v15-latin-300.eot'); /* IE9 Compat Modes */
14 | src: local('Roboto Light'), local('Roboto-Light'),
15 | url('../fonts/roboto-v15-latin-300.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
16 | url('../fonts/roboto-v15-latin-300.woff2') format('woff2'), /* Super Modern Browsers */
17 | url('../fonts/roboto-v15-latin-300.woff') format('woff'), /* Modern Browsers */
18 | url('../fonts/roboto-v15-latin-300.ttf') format('truetype'), /* Safari, Android, iOS */
19 | url('../fonts/roboto-v15-latin-300.svg#Roboto') format('svg'); /* Legacy iOS */
20 | }
21 | /* roboto-regular - latin */
22 | @font-face {
23 | font-family: 'Roboto';
24 | font-style: normal;
25 | font-weight: 400;
26 | src: url('../fonts/roboto-v15-latin-regular.eot'); /* IE9 Compat Modes */
27 | src: local('Roboto'), local('Roboto-Regular'),
28 | url('../fonts/roboto-v15-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
29 | url('../fonts/roboto-v15-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */
30 | url('../fonts/roboto-v15-latin-regular.woff') format('woff'), /* Modern Browsers */
31 | url('../fonts/roboto-v15-latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */
32 | url('../fonts/roboto-v15-latin-regular.svg#Roboto') format('svg'); /* Legacy iOS */
33 | }
34 | /* roboto-700 - latin */
35 | @font-face {
36 | font-family: 'Roboto';
37 | font-style: normal;
38 | font-weight: 700;
39 | src: url('../fonts/roboto-v15-latin-700.eot'); /* IE9 Compat Modes */
40 | src: local('Roboto Bold'), local('Roboto-Bold'),
41 | url('../fonts/roboto-v15-latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
42 | url('../fonts/roboto-v15-latin-700.woff2') format('woff2'), /* Super Modern Browsers */
43 | url('../fonts/roboto-v15-latin-700.woff') format('woff'), /* Modern Browsers */
44 | url('../fonts/roboto-v15-latin-700.ttf') format('truetype'), /* Safari, Android, iOS */
45 | url('../fonts/roboto-v15-latin-700.svg#Roboto') format('svg'); /* Legacy iOS */
46 | }
47 |
48 | h1 {
49 | font-size: 26px;
50 | }
51 | h2 {
52 | font-size: 22px;
53 | }
54 | h3 {
55 | font-size: 20px;
56 | }
57 | h4, h5 {
58 | font-size: 18px;
59 | }
60 |
61 | /**
62 | * Mobile navbar
63 | */
64 |
65 | .navbar {
66 | min-height: 50px;
67 | }
68 |
69 | .navbar-brand {
70 | height: 50px;
71 | font-size: 14px;
72 | line-height: 20px;
73 | padding: 15px;
74 | }
75 |
76 | .navbar-static-top {
77 | margin-bottom: 0;
78 | height: 50px;
79 | }
80 |
81 |
82 | /**
83 | * Main container
84 | */
85 |
86 | .container-fluid {
87 | overflow-y: hidden;
88 | overflow-x: hidden;
89 | }
90 |
91 | .container-fluid.main {
92 | height: 100%;
93 | padding: 0;
94 | }
95 |
96 | .container-fluid.overview {
97 | margin-top: 50px;
98 | }
99 |
100 | .container-fluid.modules, .container-fluid.components, .container-fluid.directives, .container-fluid.classes, .container-fluid.injectables, .container-fluid.pipes, .content.routes table {
101 | margin-top: 25px;
102 | }
103 |
104 | .container-fluid.module {
105 | padding: 0;
106 | margin-top: 0;
107 | }
108 |
109 | .container-fluid.module h3 a {
110 | margin-left: 10px;
111 | color: #333;
112 | }
113 |
114 | .row.main {
115 | height: 100%;
116 | margin: 0;
117 | }
118 |
119 |
120 | /**
121 | * Copyright
122 | */
123 |
124 | .copyright {
125 | margin: 0;
126 | padding: 15px;
127 | text-align: center;
128 | display: flex;
129 | flex-direction: column;
130 | display: -webkit-flex;
131 | -webkit-flex-direction: column;
132 | align-items: center;
133 | -webkit-align-items: center;
134 | z-index: 1;
135 | }
136 |
137 | .copyright img {
138 | width: 80px;
139 | margin-top: 10px;
140 | }
141 |
142 | .copyright a {
143 | color: #009dff;
144 | text-decoration: underline;
145 | }
146 |
147 |
148 | /**
149 | * Content
150 | */
151 |
152 | .content {
153 | height: 100%;
154 | overflow-y: auto;
155 | -webkit-overflow-scrolling: touch;
156 | width: calc(100% - 300px);
157 | position: absolute;
158 | top: 0;
159 | left: 300px;
160 | padding: 15px 30px;
161 | }
162 |
163 | .content>h1:first-of-type {
164 | margin-top: 15px
165 | }
166 |
167 | .content>h3:first-of-type {
168 | margin-top: 5px;
169 | }
170 |
171 | .content.readme h1:first-of-type {
172 | margin-top: 0;
173 | }
174 |
175 | .content table {
176 | margin-top: 20px;
177 | }
178 |
179 |
180 | /**
181 | * Icons
182 | */
183 |
184 | .glyphicon, .fa {
185 | margin-right: 10px;
186 | }
187 |
188 | .fa-code-fork {
189 | margin-right: 14px;
190 | }
191 |
192 | .fa-long-arrow-down {
193 | margin-right: 16px;
194 | }
195 |
196 |
197 | /**
198 | * Menu
199 | */
200 |
201 | #book-search-input {
202 | padding: 6px;
203 | background: 0 0;
204 | transition: top .5s ease;
205 | background: #fff;
206 | border-bottom: 1px solid rgba(0, 0, 0, .07);
207 | border-top: 1px solid rgba(0, 0, 0, .07);
208 | margin-bottom: 5px;
209 | margin-top: -1px
210 | }
211 |
212 | #book-search-input input, #book-search-input input:focus, #book-search-input input:hover {
213 | width: 100%;
214 | background: 0 0;
215 | border: 1px solid transparent;
216 | box-shadow: none;
217 | outline: 0;
218 | line-height: 22px;
219 | padding: 7px 7px;
220 | color: inherit
221 | }
222 |
223 | .panel-body {
224 | padding: 0px;
225 | }
226 |
227 | .panel-group .panel-heading+.panel-collapse>.list-group, .panel-group .panel-heading+.panel-collapse>.panel-body {
228 | border-top: 0;
229 | }
230 |
231 | .panel-body table tr td {
232 | padding-left: 15px
233 | }
234 |
235 | .panel-body .table {
236 | margin-bottom: 0px;
237 | }
238 |
239 | .panel-group .panel:first-child {
240 | border-top: 0;
241 | }
242 |
243 | .menu {
244 | background: #fafafa;
245 | border-right: 1px solid #e7e7e7;
246 | height: 100%;
247 | padding: 0;
248 | width: 300px;
249 | overflow-y: auto;
250 | -webkit-overflow-scrolling: touch;
251 | }
252 |
253 | .menu ul.list {
254 | list-style: none;
255 | margin: 0;
256 | padding: 0;
257 | }
258 |
259 | .menu ul.list li a {
260 | display: block;
261 | padding: 10px 15px;
262 | border-bottom: none;
263 | color: #364149;
264 | background: 0 0;
265 | text-overflow: ellipsis;
266 | overflow: hidden;
267 | white-space: nowrap;
268 | position: relative
269 | }
270 |
271 | .menu ul.list li a.active {
272 | color: #008cff;
273 | }
274 |
275 | .menu ul.list li.divider {
276 | height: 1px;
277 | margin: 7px 0;
278 | overflow: hidden;
279 | background: rgba(0, 0, 0, .07)
280 | }
281 |
282 | .menu ul.list li.chapter ul.links {
283 | padding-left: 20px;
284 | }
285 |
286 | .menu ul.list li.chapter .simple {
287 | padding: 10px 15px;
288 | position: relative;
289 | }
290 |
291 | .menu .panel-group {
292 | width: 100%;
293 | height: 100%;
294 | overflow-y: auto;
295 | }
296 |
297 | .menu .panel-default {
298 | border-right: none;
299 | border-left: none;
300 | border-bottom: none;
301 | }
302 |
303 | .menu .panel-group .panel-heading+.panel-collapse>.panel-body {
304 | border-top: none;
305 | overflow-y: auto;
306 | max-height: 350px;
307 | }
308 |
309 | .menu .panel-default:last-of-type {
310 | border-bottom: 1px solid #ddd;
311 | }
312 |
313 | .panel-group .panel+.panel {
314 | margin-top: 0;
315 | }
316 |
317 | .panel-group .panel {
318 | z-index: 2;
319 | position: relative;
320 | border-radius: 0;
321 | box-shadow: none;
322 | border-left: 0;
323 | border-right: 0;
324 | }
325 |
326 | .menu a {
327 | color: #3c3c3c;
328 | }
329 |
330 | .xs-menu ul.list li:nth-child(2){
331 | margin: 0;
332 | background: none;
333 | }
334 | .menu ul.list li:nth-child(2){
335 | margin: 0;
336 | background: none;
337 | }
338 | .menu .title {
339 | padding: 8px 0;
340 | }
341 |
342 | .menu-toggler {
343 | cursor: pointer;
344 | padding: 5px 10px;
345 | font-size: 16px;
346 | position: absolute;
347 | right: 0;
348 | top: 7px;
349 | }
350 |
351 | .overview .card-title .fa {
352 | font-size: 50px;
353 | }
354 |
355 | .breadcrumb {
356 | background: none;
357 | padding-left: 0;
358 | margin-bottom: 10px;
359 | font-size: 24px;
360 | padding-top: 0;
361 | }
362 |
363 | .breadcrumb a {
364 | text-decoration: underline;
365 | color: #333;
366 | }
367 |
368 | .comment {
369 | margin: 15px 0;
370 | }
371 |
372 | .io-description {
373 | margin: 10px 0;
374 | }
375 |
376 | .io-file {
377 | margin: 20px 0;
378 | }
379 |
380 | .navbar .btn-menu {
381 | position: absolute;
382 | right: 0;
383 | margin: 10px;
384 | }
385 |
386 | .xs-menu {
387 | height: calc(100% - 50px);
388 | display: none;
389 | width: 100%;
390 | overflow-y: scroll;
391 | z-index: 1;
392 | top: 50px;
393 | position: absolute;
394 | }
395 |
396 | .xs-menu .copyright {
397 | margin-top: 20px;
398 | position: relative;
399 | }
400 |
401 | .tab-source-code {
402 | padding: 10px 0;
403 | }
404 |
405 | pre {
406 | padding: 12px 12px;
407 | border: none;
408 | background: #23241f;
409 | }
410 | code {
411 | background: none;
412 | padding: 2px 0;
413 | }
414 |
415 | @media (max-width: 767px) {
416 | .container-fluid {
417 | margin-top: 50px;
418 | }
419 | .container-fluid.main {
420 | height: calc(100% - 50px);
421 | }
422 | .content {
423 | width: 100%;
424 | left: 0;
425 | position: relative;
426 | }
427 | .menu ul.list li.title {
428 | display: none;
429 | }
430 | }
431 |
432 | /**
433 | * Search
434 | */
435 |
436 | .search-results {
437 | display: none;
438 | max-width: 800px;
439 | margin: 0 auto;
440 | padding: 20px 15px 40px 15px
441 | }
442 | .search-results .no-results {
443 | display: none;
444 | }
445 |
446 | .with-search .search-results {
447 | display: block;
448 | }
449 | .with-search .content-data {
450 | display: none;
451 | }
452 |
453 | .with-search .xs-menu {
454 | height: 51px;
455 | }
456 | .with-search .xs-menu nav {
457 | display: none;
458 | }
459 |
460 | .search-results.no-results .has-results {
461 | display: none;
462 | }
463 |
464 | .search-results.no-results .no-results {
465 | display: block;
466 | }
467 | .search-results .search-results-title {
468 | text-transform: uppercase;
469 | text-align: center;
470 | font-weight: 200;
471 | margin-bottom: 35px;
472 | opacity: .6
473 | }
474 | .search-results ul.search-results-list {
475 | list-style-type: none;
476 | padding-left: 0;
477 | }
478 | .search-results ul.search-results-list li {
479 | margin-bottom: 1.5rem;
480 | padding-bottom: 0.5rem;
481 | }
482 | .search-results ul.search-results-list li p em {
483 | background-color: rgba(255, 220, 0, 0.4);
484 | font-style: normal;
485 | }
486 |
487 | .hljs-line-numbers {
488 | text-align: right;
489 | border-right: 1px solid #ccc;
490 | color: #999;
491 | -webkit-touch-callout: none;
492 | -webkit-user-select: none;
493 | -khtml-user-select: none;
494 | -moz-user-select: none;
495 | -ms-user-select: none;
496 | user-select: none;
497 | }
498 |
499 | .jsdoc-params {
500 | list-style: square;
501 | padding-left: 20px;
502 | margin-top: 10px;
503 | margin-bottom: 0 !important;
504 | }
505 | .jsdoc-params li {
506 | padding-bottom: 10px;
507 | }
508 |
509 | i {
510 | font-style: italic;
511 | }
512 |
513 | .coverage a {
514 | color: #333;
515 | text-decoration: underline;
516 | }
517 |
518 | .coverage tr.low {
519 | background: rgba(216, 96, 75, 0.75);
520 | }
521 | .coverage tr.medium {
522 | background: rgba(218, 178, 38, 0.75);
523 | }
524 | .coverage tr.good {
525 | background: rgba(143, 189, 8, 0.75);
526 | }
527 | .coverage tr.very-good {
528 | background: rgba(77, 199, 31, 0.75);
529 | }
530 |
531 | .coverage-header {
532 | background: #fafafa;
533 | }
534 | thead.coverage-header >tr>td, thead.coverage-header>tr>th {
535 | border-bottom-width: 0;
536 | }
537 | .coverage-count {
538 | color: grey;
539 | font-size: 12px;
540 | margin-left: 10px;
541 | display: inline-block;
542 | width: 50px;
543 | }
544 | .coverage-badge {
545 | background: #5d5d5d;
546 | border-radius: 4px;
547 | display: inline-block;
548 | color: white;
549 | padding: 4px;
550 | padding-right: 0;
551 | padding-left: 8px;
552 | }
553 | .coverage-badge .count{
554 | padding: 6px;
555 | margin-left: 5px;
556 | border-top-right-radius: 4px;
557 | border-bottom-right-radius: 4px;
558 | }
559 | .coverage-badge .count.low {
560 | background: #d8624c;
561 | }
562 | .coverage-badge .count.medium {
563 | background: #dab226;
564 | }
565 | .coverage-badge .count.good {
566 | background: #8fbd08;
567 | }
568 | .coverage-badge .count.very-good {
569 | background: #4dc71f;
570 | }
571 |
572 | .content ul {
573 | list-style: disc;
574 | padding-left: 2em;
575 | margin-top: 0;
576 | margin-bottom: 16px;
577 | }
578 | .content ul ul {
579 | list-style-type: circle;
580 | }
581 | .compodoc-table {
582 | width: inherit;
583 | }
584 | .compodoc-table thead {
585 | font-weight: bold;
586 | }
587 | .modifier {
588 | background: #9a9a9a;
589 | padding: 1px 5px;
590 | color: white;
591 | border-radius: 4px;
592 | }
593 | .modifier-icon {
594 | color: #c7254e;
595 | }
596 | .modifier-icon.method {
597 | color: white;
598 | background: #c7254e;
599 | padding: 4px;
600 | border-radius: 8px;
601 | font-size: 10px;
602 | margin-right: 2px;
603 | }
604 | .modifier-icon.method.square {
605 | border-radius: 4px;
606 | }
607 | .modifier-icon.method.export {
608 | display: none;
609 | }
610 | .modifier-icon.method .fa-circle, .modifier-icon.method .fa-square {
611 | display: none;
612 | }
613 | .modifier-icon.method .fa-lock {
614 | margin-right: 0;
615 | }
616 |
--------------------------------------------------------------------------------
/documentation/styles/laravel.css:
--------------------------------------------------------------------------------
1 | .navbar-default .navbar-brand {
2 | color: #f4645f;
3 | text-decoration: none;
4 | font-size: 16px;
5 | }
6 |
7 | .menu ul.list li a[data-type="chapter-link"], .menu ul.list li.chapter .simple {
8 | color: #525252;
9 | border-bottom: 1px dashed rgba(0,0,0,.1);
10 | }
11 |
12 | .content h1, .content h2, .content h3, .content h4, .content h5 {
13 | color: #292e31;
14 | font-weight: normal;
15 | }
16 |
17 | .content {
18 | color: #4c555a;
19 | }
20 |
21 | a {
22 | color: #f4645f;
23 | text-decoration: underline;
24 | }
25 | a:hover {
26 | color: #f1362f;
27 | }
28 |
29 | .menu ul.list li:nth-child(2) {
30 | margin-top: 0;
31 | }
32 |
33 | .menu ul.list li.title a {
34 | color: #f4645f;
35 | text-decoration: none;
36 | font-size: 16px;
37 | }
38 |
39 | .menu ul.list li a {
40 | color: #f4645f;
41 | text-decoration: none;
42 | }
43 | .menu ul.list li a.active {
44 | color: #f4645f;
45 | font-weight: bold;
46 | }
47 |
48 | code {
49 | box-sizing: border-box;
50 | display: inline-block;
51 | padding: 0 5px;
52 | background: #f0f2f1;
53 | border: 1px solid #f0f4f7;
54 | border-radius: 3px;
55 | color: #b93d6a;
56 | font-size: 13px;
57 | line-height: 20px;
58 | box-shadow: 0 1px 1px rgba(0,0,0,.125);
59 | }
60 |
61 | pre {
62 | margin: 0;
63 | padding: 12px 12px;
64 | background: rgba(238,238,238,.35);
65 | border-radius: 3px;
66 | font-size: 13px;
67 | line-height: 1.5em;
68 | font-weight: 500;
69 | box-shadow: 0 1px 1px rgba(0,0,0,.125);
70 | }
71 |
72 | pre code.hljs {
73 | border: none;
74 | background: none;
75 | box-shadow: none;
76 | }
77 |
78 | /*
79 | Atom One Light by Daniel Gamage
80 | Original One Light Syntax theme from https://github.com/atom/one-light-syntax
81 | base: #fafafa
82 | mono-1: #383a42
83 | mono-2: #686b77
84 | mono-3: #a0a1a7
85 | hue-1: #0184bb
86 | hue-2: #4078f2
87 | hue-3: #a626a4
88 | hue-4: #50a14f
89 | hue-5: #e45649
90 | hue-5-2: #c91243
91 | hue-6: #986801
92 | hue-6-2: #c18401
93 | */
94 |
95 | .hljs {
96 | display: block;
97 | overflow-x: auto;
98 | padding: 0.5em;
99 | color: #383a42;
100 | background: #fafafa;
101 | }
102 |
103 | .hljs-comment,
104 | .hljs-quote {
105 | color: #a0a1a7;
106 | font-style: italic;
107 | }
108 |
109 | .hljs-doctag,
110 | .hljs-keyword,
111 | .hljs-formula {
112 | color: #a626a4;
113 | }
114 |
115 | .hljs-section,
116 | .hljs-name,
117 | .hljs-selector-tag,
118 | .hljs-deletion,
119 | .hljs-subst {
120 | color: #e45649;
121 | }
122 |
123 | .hljs-literal {
124 | color: #0184bb;
125 | }
126 |
127 | .hljs-string,
128 | .hljs-regexp,
129 | .hljs-addition,
130 | .hljs-attribute,
131 | .hljs-meta-string {
132 | color: #50a14f;
133 | }
134 |
135 | .hljs-built_in,
136 | .hljs-class .hljs-title {
137 | color: #c18401;
138 | }
139 |
140 | .hljs-attr,
141 | .hljs-variable,
142 | .hljs-template-variable,
143 | .hljs-type,
144 | .hljs-selector-class,
145 | .hljs-selector-attr,
146 | .hljs-selector-pseudo,
147 | .hljs-number {
148 | color: #986801;
149 | }
150 |
151 | .hljs-symbol,
152 | .hljs-bullet,
153 | .hljs-link,
154 | .hljs-meta,
155 | .hljs-selector-id,
156 | .hljs-title {
157 | color: #4078f2;
158 | }
159 |
160 | .hljs-emphasis {
161 | font-style: italic;
162 | }
163 |
164 | .hljs-strong {
165 | font-weight: bold;
166 | }
167 |
168 | .hljs-link {
169 | text-decoration: underline;
170 | }
171 |
--------------------------------------------------------------------------------
/documentation/styles/monokai-sublime.css:
--------------------------------------------------------------------------------
1 | /*
2 |
3 | Monokai Sublime style. Derived from Monokai by noformnocontent http://nn.mit-license.org/
4 |
5 | */
6 |
7 | .hljs {
8 | display: block;
9 | overflow-x: auto;
10 | padding: 0.5em;
11 | background: #23241f;
12 | }
13 |
14 | .hljs,
15 | .hljs-tag,
16 | .hljs-subst {
17 | color: #f8f8f2;
18 | }
19 |
20 | .hljs-strong,
21 | .hljs-emphasis {
22 | color: #a8a8a2;
23 | }
24 |
25 | .hljs-bullet,
26 | .hljs-quote,
27 | .hljs-number,
28 | .hljs-regexp,
29 | .hljs-literal,
30 | .hljs-link {
31 | color: #ae81ff;
32 | }
33 |
34 | .hljs-code,
35 | .hljs-title,
36 | .hljs-section,
37 | .hljs-selector-class {
38 | color: #a6e22e;
39 | }
40 |
41 | .hljs-strong {
42 | font-weight: bold;
43 | }
44 |
45 | .hljs-emphasis {
46 | font-style: italic;
47 | }
48 |
49 | .hljs-keyword,
50 | .hljs-selector-tag,
51 | .hljs-name,
52 | .hljs-attr {
53 | color: #f92672;
54 | }
55 |
56 | .hljs-symbol,
57 | .hljs-attribute {
58 | color: #66d9ef;
59 | }
60 |
61 | .hljs-params,
62 | .hljs-class .hljs-title {
63 | color: #f8f8f2;
64 | }
65 |
66 | .hljs-string,
67 | .hljs-type,
68 | .hljs-built_in,
69 | .hljs-builtin-name,
70 | .hljs-selector-id,
71 | .hljs-selector-attr,
72 | .hljs-selector-pseudo,
73 | .hljs-addition,
74 | .hljs-variable,
75 | .hljs-template-variable {
76 | color: #e6db74;
77 | }
78 |
79 | .hljs-comment,
80 | .hljs-deletion,
81 | .hljs-meta {
82 | color: #75715e;
83 | }
84 |
--------------------------------------------------------------------------------
/documentation/styles/original.css:
--------------------------------------------------------------------------------
1 | .navbar-default .navbar-brand, .menu ul.list li.title {
2 | font-weight: bold;
3 | color: #3c3c3c;
4 | padding-bottom: 5px;
5 | }
6 |
7 | .menu ul.list li a[data-type="chapter-link"], .menu ul.list li.chapter .simple {
8 | font-weight: bold;
9 | border-top: 1px solid #ddd;
10 | border-bottom: 1px solid #ddd;
11 | font-size: 14px;
12 | }
13 |
14 | .menu ul.list li a[href="./routes.html"] {
15 | border-bottom: none;
16 | }
17 |
18 | .menu ul.list > li:nth-child(2) {
19 | display: none;
20 | }
21 |
22 | .menu ul.list li.chapter ul.links {
23 | background: #fff;
24 | padding-left: 0;
25 | }
26 |
27 | .menu ul.list li.chapter ul.links li {
28 | border-bottom: 1px solid #ddd;
29 | padding-left: 20px;
30 | }
31 |
32 | .menu ul.list li.chapter ul.links li:last-child {
33 | border-bottom: none;
34 | }
35 |
36 | .menu ul.list li a.active {
37 | color: inherit;
38 | font-weight: bold;
39 | }
40 |
41 | #book-search-input {
42 | margin-bottom: 0;
43 | border-bottom: none;
44 | }
45 | .menu ul.list li.divider {
46 | margin: 0;
47 | }
48 |
--------------------------------------------------------------------------------
/documentation/styles/postmark.css:
--------------------------------------------------------------------------------
1 | .navbar-default {
2 | background: #FFDE00;
3 | border: none;
4 | }
5 | .navbar-default .navbar-brand {
6 | color: #333;
7 | font-weight: bold;
8 | }
9 | .menu {
10 | background: #333;
11 | color: #fcfcfc;
12 | }
13 | .menu ul.list li a {
14 | color: #333;
15 | }
16 |
17 | .menu ul.list li.title {
18 | background: #FFDE00;
19 | color: #333;
20 | padding-bottom: 5px;
21 | }
22 |
23 | .menu ul.list li:nth-child(2) {
24 | margin-top: 0;
25 | }
26 |
27 | .menu ul.list li.chapter a, .menu ul.list li.chapter .simple {
28 | color: white;
29 | text-decoration: none;
30 | }
31 |
32 | .menu ul.list li.chapter ul.links a {
33 | color: #949494;
34 | text-transform: none;
35 | padding-left: 35px;
36 | }
37 | .menu ul.list li.chapter ul.links a:hover, .menu ul.list li.chapter ul.links a.active {
38 | color: #FFDE00;
39 | }
40 |
41 | .menu ul.list li.chapter ul.links {
42 | padding-left: 0;
43 | }
44 |
45 | .menu ul.list li.divider {
46 | background: rgba(255, 255, 255, 0.07);
47 | }
48 |
49 | #book-search-input input, #book-search-input input:focus, #book-search-input input:hover {
50 | color: #949494;
51 | }
52 |
53 | .copyright {
54 | color: #b3b3b3;
55 | }
56 |
57 | .content {
58 | background: #fcfcfc;
59 | }
60 |
61 | .content a {
62 | color: #007DCC;
63 | }
64 | .content a:visited {
65 | color: #0165a5;
66 | }
67 | .copyright {
68 | background: #272525;
69 | }
70 | .menu ul.list li:nth-last-child(2) {
71 | background: none;
72 | }
73 | .list-group-item:first-child, .list-group-item:last-child {
74 | border-radius: 0;
75 | }
76 |
77 | .menu ul.list li.title a {
78 | text-decoration: none;
79 | font-weight: bold;
80 | }
81 | .menu ul.list li.title a:hover {
82 | background: rgba(255,255,255,0.1);
83 | }
84 |
85 | .breadcrumb>li+li:before {
86 | content: "»\00a0"
87 | }
88 |
89 | .breadcrumb {
90 | padding-bottom: 15px;
91 | border-bottom: 1px solid #e1e4e5;
92 | }
93 | code {
94 | white-space: nowrap;
95 | max-width: 100%;
96 | background: #F5F5F5;
97 | border: solid 1px #e1e4e5;
98 | padding: 2px 5px;
99 | color: #666666;
100 | overflow-x: auto;
101 | border-radius: 0;
102 | }
103 | pre {
104 | white-space: pre;
105 | margin: 0;
106 | padding: 12px 12px;
107 | font-size: 12px;
108 | line-height: 1.5;
109 | display: block;
110 | overflow: auto;
111 | color: #404040;
112 | background: #f3f3f3;
113 | }
114 | pre code.hljs {
115 | border: none;
116 | background: inherit;
117 | }
118 |
119 | /*
120 | Atom One Light by Daniel Gamage
121 | Original One Light Syntax theme from https://github.com/atom/one-light-syntax
122 | base: #fafafa
123 | mono-1: #383a42
124 | mono-2: #686b77
125 | mono-3: #a0a1a7
126 | hue-1: #0184bb
127 | hue-2: #4078f2
128 | hue-3: #a626a4
129 | hue-4: #50a14f
130 | hue-5: #e45649
131 | hue-5-2: #c91243
132 | hue-6: #986801
133 | hue-6-2: #c18401
134 | */
135 |
136 | .hljs {
137 | display: block;
138 | overflow-x: auto;
139 | padding: 0.5em;
140 | color: #383a42;
141 | background: #fafafa;
142 | }
143 |
144 | .hljs-comment,
145 | .hljs-quote {
146 | color: #a0a1a7;
147 | font-style: italic;
148 | }
149 |
150 | .hljs-doctag,
151 | .hljs-keyword,
152 | .hljs-formula {
153 | color: #a626a4;
154 | }
155 |
156 | .hljs-section,
157 | .hljs-name,
158 | .hljs-selector-tag,
159 | .hljs-deletion,
160 | .hljs-subst {
161 | color: #e45649;
162 | }
163 |
164 | .hljs-literal {
165 | color: #0184bb;
166 | }
167 |
168 | .hljs-string,
169 | .hljs-regexp,
170 | .hljs-addition,
171 | .hljs-attribute,
172 | .hljs-meta-string {
173 | color: #50a14f;
174 | }
175 |
176 | .hljs-built_in,
177 | .hljs-class .hljs-title {
178 | color: #c18401;
179 | }
180 |
181 | .hljs-attr,
182 | .hljs-variable,
183 | .hljs-template-variable,
184 | .hljs-type,
185 | .hljs-selector-class,
186 | .hljs-selector-attr,
187 | .hljs-selector-pseudo,
188 | .hljs-number {
189 | color: #986801;
190 | }
191 |
192 | .hljs-symbol,
193 | .hljs-bullet,
194 | .hljs-link,
195 | .hljs-meta,
196 | .hljs-selector-id,
197 | .hljs-title {
198 | color: #4078f2;
199 | }
200 |
201 | .hljs-emphasis {
202 | font-style: italic;
203 | }
204 |
205 | .hljs-strong {
206 | font-weight: bold;
207 | }
208 |
209 | .hljs-link {
210 | text-decoration: underline;
211 | }
212 |
--------------------------------------------------------------------------------
/documentation/styles/readthedocs.css:
--------------------------------------------------------------------------------
1 | .navbar-default {
2 | background: #2980B9;
3 | border: none;
4 | }
5 | .navbar-default .navbar-brand {
6 | color: #fcfcfc;
7 | }
8 | .menu {
9 | background: #343131;
10 | color: #fcfcfc;
11 | }
12 | .menu ul.list li a {
13 | color: #fcfcfc;
14 | }
15 |
16 | .menu ul.list li a.active {
17 | color: #0099e5;
18 | }
19 |
20 | .menu ul.list li.title {
21 | background: #2980B9;
22 | padding-bottom: 5px;
23 | }
24 |
25 | .menu ul.list li:nth-child(2) {
26 | margin-top: 0;
27 | }
28 |
29 | .menu ul.list li.chapter a, .menu ul.list li.chapter .simple {
30 | color: #555;
31 | text-transform: uppercase;
32 | text-decoration: none;
33 | }
34 |
35 | .menu ul.list li.chapter ul.links a {
36 | color: #b3b3b3;
37 | text-transform: none;
38 | padding-left: 35px;
39 | }
40 | .menu ul.list li.chapter ul.links a:hover {
41 | background: #4E4A4A;
42 | }
43 |
44 | .menu ul.list li.chapter ul.links {
45 | padding-left: 0;
46 | }
47 |
48 | .menu ul.list li.divider {
49 | background: rgba(255, 255, 255, 0.07);
50 | }
51 |
52 | #book-search-input input, #book-search-input input:focus, #book-search-input input:hover {
53 | color: #949494;
54 | }
55 |
56 | .copyright {
57 | color: #b3b3b3;
58 | }
59 |
60 | .content {
61 | background: #fcfcfc;
62 | }
63 |
64 | .content a {
65 | color: #2980B9;
66 | }
67 | .content a:hover {
68 | color: #3091d1;
69 | }
70 | .content a:visited {
71 | color: #9B59B6;
72 | }
73 | .copyright {
74 | background: #272525;
75 | }
76 | .menu ul.list li:nth-last-child(2) {
77 | background: none;
78 | }
79 | code {
80 | white-space: nowrap;
81 | max-width: 100%;
82 | background: #fff;
83 | border: solid 1px #e1e4e5;
84 | padding: 2px 5px;
85 | color: #E74C3C;
86 | overflow-x: auto;
87 | border-radius: 0;
88 | }
89 | pre {
90 | white-space: pre;
91 | margin: 0;
92 | padding: 12px 12px;
93 | font-size: 12px;
94 | line-height: 1.5;
95 | display: block;
96 | overflow: auto;
97 | color: #404040;
98 | background: rgba(238,238,238,.35);
99 | }
100 | pre code.hljs {
101 | border: none;
102 | background: inherit;
103 | }
104 |
105 | /*
106 | Atom One Light by Daniel Gamage
107 | Original One Light Syntax theme from https://github.com/atom/one-light-syntax
108 | base: #fafafa
109 | mono-1: #383a42
110 | mono-2: #686b77
111 | mono-3: #a0a1a7
112 | hue-1: #0184bb
113 | hue-2: #4078f2
114 | hue-3: #a626a4
115 | hue-4: #50a14f
116 | hue-5: #e45649
117 | hue-5-2: #c91243
118 | hue-6: #986801
119 | hue-6-2: #c18401
120 | */
121 |
122 | .hljs {
123 | display: block;
124 | overflow-x: auto;
125 | padding: 0.5em;
126 | color: #383a42;
127 | background: #fafafa;
128 | }
129 |
130 | .hljs-comment,
131 | .hljs-quote {
132 | color: #a0a1a7;
133 | font-style: italic;
134 | }
135 |
136 | .hljs-doctag,
137 | .hljs-keyword,
138 | .hljs-formula {
139 | color: #a626a4;
140 | }
141 |
142 | .hljs-section,
143 | .hljs-name,
144 | .hljs-selector-tag,
145 | .hljs-deletion,
146 | .hljs-subst {
147 | color: #e45649;
148 | }
149 |
150 | .hljs-literal {
151 | color: #0184bb;
152 | }
153 |
154 | .hljs-string,
155 | .hljs-regexp,
156 | .hljs-addition,
157 | .hljs-attribute,
158 | .hljs-meta-string {
159 | color: #50a14f;
160 | }
161 |
162 | .hljs-built_in,
163 | .hljs-class .hljs-title {
164 | color: #c18401;
165 | }
166 |
167 | .hljs-attr,
168 | .hljs-variable,
169 | .hljs-template-variable,
170 | .hljs-type,
171 | .hljs-selector-class,
172 | .hljs-selector-attr,
173 | .hljs-selector-pseudo,
174 | .hljs-number {
175 | color: #986801;
176 | }
177 |
178 | .hljs-symbol,
179 | .hljs-bullet,
180 | .hljs-link,
181 | .hljs-meta,
182 | .hljs-selector-id,
183 | .hljs-title {
184 | color: #4078f2;
185 | }
186 |
187 | .hljs-emphasis {
188 | font-style: italic;
189 | }
190 |
191 | .hljs-strong {
192 | font-weight: bold;
193 | }
194 |
195 | .hljs-link {
196 | text-decoration: underline;
197 | }
198 |
199 | .list-group-item:first-child, .list-group-item:last-child {
200 | border-radius: 0;
201 | }
202 |
203 | .menu ul.list li.title a {
204 | text-decoration: none;
205 | }
206 | .menu ul.list li.title a:hover {
207 | background: rgba(255,255,255,0.1);
208 | }
209 |
210 | .breadcrumb>li+li:before {
211 | content: "»\00a0"
212 | }
213 |
214 | .breadcrumb {
215 | padding-bottom: 15px;
216 | border-bottom: 1px solid #e1e4e5;
217 | }
218 |
--------------------------------------------------------------------------------
/documentation/styles/reset.css:
--------------------------------------------------------------------------------
1 | /* http://meyerweb.com/eric/tools/css/reset/
2 | v2.0 | 20110126
3 | License: none (public domain)
4 | */
5 |
6 | html, body, div, span, applet, object, iframe,
7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre,
8 | a, abbr, acronym, address, big, cite, code,
9 | del, dfn, em, img, ins, kbd, q, s, samp,
10 | small, strike, strong, sub, sup, tt, var,
11 | b, u, i, center,
12 | dl, dt, dd, ol, ul, li,
13 | fieldset, form, label, legend,
14 | table, caption, tbody, tfoot, thead, tr, th, td,
15 | article, aside, canvas, details, embed,
16 | figure, figcaption, footer, header, hgroup,
17 | menu, nav, output, ruby, section, summary,
18 | time, mark, audio, video {
19 | margin: 0;
20 | padding: 0;
21 | border: 0;
22 | font-size: 100%;
23 | font: inherit;
24 | vertical-align: baseline;
25 | }
26 | /* HTML5 display-role reset for older browsers */
27 | article, aside, details, figcaption, figure,
28 | footer, header, hgroup, menu, nav, section {
29 | display: block;
30 | }
31 | body {
32 | line-height: 1;
33 | }
34 | ol, ul {
35 | list-style: none;
36 | }
37 | blockquote, q {
38 | quotes: none;
39 | }
40 | blockquote:before, blockquote:after,
41 | q:before, q:after {
42 | content: '';
43 | content: none;
44 | }
45 | table {
46 | border-collapse: collapse;
47 | border-spacing: 0;
48 | }
--------------------------------------------------------------------------------
/documentation/styles/stripe.css:
--------------------------------------------------------------------------------
1 | .navbar-default .navbar-brand {
2 | color: #0099e5;
3 | }
4 |
5 | .menu ul.list li a[data-type="chapter-link"], .menu ul.list li.chapter .simple {
6 | color: #939da3;
7 | text-transform: uppercase;
8 | }
9 |
10 | .content h1, .content h2, .content h3, .content h4, .content h5 {
11 | color: #292e31;
12 | font-weight: normal;
13 | }
14 |
15 | .content {
16 | color: #4c555a;
17 | }
18 |
19 | .menu ul.list li.title {
20 | padding: 5px 0;
21 | }
22 |
23 | a {
24 | color: #0099e5;
25 | text-decoration: none;
26 | }
27 | a:hover {
28 | color: #292e31;
29 | text-decoration: none;
30 | }
31 |
32 | .menu ul.list li:nth-child(2) {
33 | margin-top: 0;
34 | }
35 |
36 | .menu ul.list li.title a, .navbar a {
37 | color: #0099e5;
38 | text-decoration: none;
39 | font-size: 16px;
40 | }
41 |
42 | .menu ul.list li a.active {
43 | color: #0099e5;
44 | }
45 |
46 | code {
47 | box-sizing: border-box;
48 | display: inline-block;
49 | padding: 0 5px;
50 | background: #fafcfc;
51 | border: 1px solid #f0f4f7;
52 | border-radius: 4px;
53 | color: #b93d6a;
54 | font-size: 13px;
55 | line-height: 20px
56 | }
57 |
58 | pre {
59 | margin: 0;
60 | padding: 12px 12px;
61 | background: #272b2d;
62 | border-radius: 5px;
63 | font-size: 13px;
64 | line-height: 1.5em;
65 | font-weight: 500
66 | }
67 |
68 | pre code.hljs {
69 | border: none;
70 | background: #272b2d;
71 | }
72 |
--------------------------------------------------------------------------------
/documentation/styles/style.css:
--------------------------------------------------------------------------------
1 | @import "./reset.css";
2 | @import "./bootstrap.min.css";
3 | @import "./bootstrap-card.css";
4 | @import "./monokai-sublime.css";
5 | @import "./font-awesome.min.css";
6 | @import "./compodoc.css";
7 |
--------------------------------------------------------------------------------
/documentation/styles/vagrant.css:
--------------------------------------------------------------------------------
1 | .navbar-default .navbar-brand {
2 | background: white;
3 | color: #8d9ba8;
4 | }
5 |
6 | .menu .list {
7 | background: #0c5593;
8 | }
9 |
10 | .menu .chapter {
11 | padding: 0 20px;
12 | }
13 |
14 | .menu ul.list li a[data-type="chapter-link"], .menu ul.list li.chapter .simple {
15 | color: white;
16 | text-transform: uppercase;
17 | border-bottom: 1px solid rgba(255,255,255,0.4);
18 | }
19 |
20 | .content h1, .content h2, .content h3, .content h4, .content h5 {
21 | color: #292e31;
22 | font-weight: normal;
23 | }
24 |
25 | .content {
26 | color: #4c555a;
27 | }
28 |
29 | a {
30 | color: #0094bf;
31 | text-decoration: underline;
32 | }
33 | a:hover {
34 | color: #f1362f;
35 | }
36 |
37 | .menu ul.list li.title {
38 | background: white;
39 | padding-bottom: 5px;
40 | }
41 |
42 | .menu ul.list li:nth-child(2) {
43 | margin-top: 0;
44 | }
45 |
46 | .menu ul.list li:nth-last-child(2) {
47 | background: none;
48 | }
49 |
50 | .menu ul.list li.title a {
51 | padding: 10px 15px;
52 | }
53 |
54 | .menu ul.list li.title a, .navbar a {
55 | color: #8d9ba8;
56 | text-decoration: none;
57 | font-size: 16px;
58 | font-weight: 300;
59 | }
60 |
61 | .menu ul.list li a {
62 | color: white;
63 | padding: 10px;
64 | font-weight: 300;
65 | text-decoration: none;
66 | }
67 | .menu ul.list li a.active {
68 | color: white;
69 | font-weight: bold;
70 | }
71 |
72 | .copyright {
73 | color: white;
74 | background: #000;
75 | }
76 |
77 | code {
78 | box-sizing: border-box;
79 | display: inline-block;
80 | padding: 0 5px;
81 | background: rgba(0,148,191,0.1);
82 | border: 1px solid #f0f4f7;
83 | border-radius: 3px;
84 | color: #0094bf;
85 | font-size: 13px;
86 | line-height: 20px;
87 | }
88 |
89 | pre {
90 | margin: 0;
91 | padding: 12px 12px;
92 | background: rgba(238,238,238,.35);
93 | border-radius: 3px;
94 | font-size: 13px;
95 | line-height: 1.5em;
96 | font-weight: 500;
97 | }
98 |
99 | pre code.hljs {
100 | border: none;
101 | background: none;
102 | box-shadow: none;
103 | }
104 |
105 | /*
106 | Atom One Light by Daniel Gamage
107 | Original One Light Syntax theme from https://github.com/atom/one-light-syntax
108 | base: #fafafa
109 | mono-1: #383a42
110 | mono-2: #686b77
111 | mono-3: #a0a1a7
112 | hue-1: #0184bb
113 | hue-2: #4078f2
114 | hue-3: #a626a4
115 | hue-4: #50a14f
116 | hue-5: #e45649
117 | hue-5-2: #c91243
118 | hue-6: #986801
119 | hue-6-2: #c18401
120 | */
121 |
122 | .hljs {
123 | display: block;
124 | overflow-x: auto;
125 | padding: 0.5em;
126 | color: #383a42;
127 | background: #fafafa;
128 | }
129 |
130 | .hljs-comment,
131 | .hljs-quote {
132 | color: #a0a1a7;
133 | font-style: italic;
134 | }
135 |
136 | .hljs-doctag,
137 | .hljs-keyword,
138 | .hljs-formula {
139 | color: #a626a4;
140 | }
141 |
142 | .hljs-section,
143 | .hljs-name,
144 | .hljs-selector-tag,
145 | .hljs-deletion,
146 | .hljs-subst {
147 | color: #e45649;
148 | }
149 |
150 | .hljs-literal {
151 | color: #0184bb;
152 | }
153 |
154 | .hljs-string,
155 | .hljs-regexp,
156 | .hljs-addition,
157 | .hljs-attribute,
158 | .hljs-meta-string {
159 | color: #50a14f;
160 | }
161 |
162 | .hljs-built_in,
163 | .hljs-class .hljs-title {
164 | color: #c18401;
165 | }
166 |
167 | .hljs-attr,
168 | .hljs-variable,
169 | .hljs-template-variable,
170 | .hljs-type,
171 | .hljs-selector-class,
172 | .hljs-selector-attr,
173 | .hljs-selector-pseudo,
174 | .hljs-number {
175 | color: #986801;
176 | }
177 |
178 | .hljs-symbol,
179 | .hljs-bullet,
180 | .hljs-link,
181 | .hljs-meta,
182 | .hljs-selector-id,
183 | .hljs-title {
184 | color: #4078f2;
185 | }
186 |
187 | .hljs-emphasis {
188 | font-style: italic;
189 | }
190 |
191 | .hljs-strong {
192 | font-weight: bold;
193 | }
194 |
195 | .hljs-link {
196 | text-decoration: underline;
197 | }
198 |
--------------------------------------------------------------------------------
/examples/.gitignore:
--------------------------------------------------------------------------------
1 | # compiled output
2 | /dist
3 | /node_modules
4 |
5 | # Logs
6 | logs
7 | *.log
8 | npm-debug.log*
9 | yarn-debug.log*
10 | yarn-error.log*
11 | lerna-debug.log*
12 |
13 | # OS
14 | .DS_Store
15 |
16 | # Tests
17 | /coverage
18 | /.nyc_output
19 |
20 | # IDEs and editors
21 | /.idea
22 | .project
23 | .classpath
24 | .c9/
25 | *.launch
26 | .settings/
27 | *.sublime-workspace
28 |
29 | # IDE - VSCode
30 | .vscode/*
31 | !.vscode/settings.json
32 | !.vscode/tasks.json
33 | !.vscode/launch.json
34 | !.vscode/extensions.json
--------------------------------------------------------------------------------
/examples/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all"
4 | }
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
1 | # Trying EventStoreDb connector
2 |
3 | ## Note on nodeJS version
4 |
5 | You'll find a compatible version you refer to in [this .nvmrc](../.nvmrc). You might want to use nvm direcly, and in this case, nvm will read direcly this configuration file and adjust the version.
6 |
7 | ## Installation
8 |
9 | ```
10 | yarn install
11 | ```
12 |
13 | ## Start
14 |
15 | You have to run a eventstore container with correct ports. You can put the ports you want, but it will work natively with this conf :
16 |
17 | ```
18 | docker run --name esdb-node -d \
19 | -it -p 2113:2113 -p 1113:1113 \
20 | eventstore/eventstore:latest \
21 | --insecure \
22 | --run-projections=All \
23 | --enable-atom-pub-over-http
24 | ```
25 |
26 | Then, the REST API will start by running :
27 |
28 | ```
29 | yarn start
30 | ```
31 |
32 | ## Usage
33 |
34 | #### running basic CQRS example with eventStore
35 |
36 | The example is based on [CQRS module](https://github.com/kamilmysliwiec/nest-cqrs) usage example of [Nest](https://github.com/kamilmysliwiec/nest) framework.
37 |
38 | # Story
39 |
40 | The hero fight the dragon in an epic fight.
41 | he kills the dragon, and a dragon can be killed only once.
42 | We need to keep only the last 5 move of the fight and delete them after 3 days for faery RGPD.
43 |
44 | When it's done the hero search and find an item.
45 | He can find this special item only once until he drop hit.
46 |
47 | #### How to connect
48 |
49 | You can see how the import is done in the [EventStoreHeroesModule](./src/heroes/event-store-heroes.module.ts) :
50 |
51 | ```typescript
52 | CqrsEventStoreModule.register(
53 | eventStoreConnectionConfig,
54 | eventStoreSubsystems,
55 | eventBusConfig,
56 | ),
57 | ```
58 |
59 | # Let's run it
60 |
61 | Once the API is running, you only have to run this REST request :
62 |
63 | ```
64 | curl -XPUT localhost:3000/hero/200/kill -d'{ "dragonId" : "test3" }'
65 | ```
66 |
67 | All the events emitted by the example will be stored in the event store.
68 |
69 | You can call it multiple time to try idemptotency
70 | see events on [the dashboard](http://localhost:20113/web/index.html#/streams/hero-200) of event store
71 |
72 | ## What the code does
73 |
74 | - `heroes.controller` send to the command bus `kill-dragon.command` configured with http body
75 | - `kill-dragon.handler` fetch the hero and build a `hero.aggregate` merging db and command data
76 | - `kill-dragon.handler` call killEnemy() on `hero.aggregate`
77 | - `hero.aggregate` kill the enemy and apply `hero-killed-dragon.event`
78 | - `kill-dragon.handler` commit() on `hero.aggregate` (write all events on aggregate)
79 | - Eventstore stores the event on event's stream, applying idempotency (mean if you do it twice event is ignored the other times)
80 | - `hero-killed-dragon.handler` receive the event from Eventstore and log (can run in another process)
81 | - `heroes-sagas` receive the event from Eventstore do some logic and send to commandBus `drop-ancient-item-command`
82 | - `drop-ancient-item.handler` fetch the hero and build a `hero.aggregate` merging db and command data
83 | - `drop-ancient-item.handler` addItem() on `hero.aggregate`
84 | - `hero.aggregate` apply `hero-found-item.event`
85 | - `drop-ancient-item.handler` dropItem() on `hero.aggregate`
86 | - `hero.aggregate` apply `hero-drop-item.event`
87 | - `kill-dragon.handler` commit() on `hero.aggregate` (can be done after each call)
88 | - Eventstore store the event on event's stream
89 |
90 | ## Data transfer object
91 |
92 | Stupid data object with optionnals validation rules
93 |
94 | ## Repository
95 |
96 | Link with the database (mocked here)
97 |
98 | ## Aggregate Root
99 |
100 | Where everything on an entity and it's child appends.
101 | Updated only using events
102 |
103 | ## Command
104 |
105 | Data transfer object with a name that you must execute
106 |
107 | ## Command handler
108 |
109 | Do the logic :
110 | Read the Command, merge DB and command's data on the Aggregate, play with aggregate's methods, commit one or multiple time when needed
111 |
112 | ## Event
113 |
114 | Data transfer object with a name and a stream name.
115 | Can have a UUID, metadata and an expected version
116 |
117 | ## Event Handler
118 |
119 | Do the side effects.
120 | Receive event and do logic with them : usually update or insert on the database
121 |
122 | ## Saga
123 |
124 | Side effects that create new commands.
125 | Receive events and return one or more commands.
126 |
--------------------------------------------------------------------------------
/examples/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nest-cqrs-example",
3 | "version": "1.0.0",
4 | "description": "Nest CQRS module usage example",
5 | "license": "MIT",
6 | "scripts": {
7 | "postinstall": "link-module-alias",
8 | "start": "yarn start:heroes:cqrs:eventstore",
9 | "start:heroes:cqrs:eventstore": "ts-node-dev src/bin/runner.ts"
10 | },
11 | "dependencies": {
12 | "@godaddy/terminus": "^4.4.1",
13 | "@nestjs/common": "^7.0.0",
14 | "@nestjs/core": "^7.0.0",
15 | "@nestjs/cqrs": "^7.0.1",
16 | "@nestjs/platform-express": "^6.0.0",
17 | "@nestjs/terminus": "^7.0.1",
18 | "class-transformer": "^0.4.0",
19 | "class-validator": "^0.13.1",
20 | "cli-color": "^1.4.0",
21 | "global": "^4.4.0",
22 | "nestjs-context": "^0.12.0",
23 | "nestjs-geteventstore": "^5.0.19",
24 | "nestjs-pino-stackdriver": "^2.1.0",
25 | "reflect-metadata": "^0.1.13",
26 | "rxjs": "^7.0.0"
27 | },
28 | "devDependencies": {
29 | "@compodoc/compodoc": "^1.1.11",
30 | "@types/express": "^4.16.1",
31 | "@types/node": "^11.9.4",
32 | "@typescript-eslint/eslint-plugin": "^4.28.4",
33 | "@typescript-eslint/parser": "^4.28.4",
34 | "dot-prop": ">=4.2.1",
35 | "eslint": "^7.31.0",
36 | "eslint-plugin-import": "^2.23.4",
37 | "eslint-plugin-jest": "^24.4.0",
38 | "eslint-plugin-local": "^1.0.0",
39 | "link-module-alias": "^1.2.0",
40 | "nodemon": "^1.18.10",
41 | "prettier": "^1.12.1",
42 | "serialize-javascript": ">=3.1.0",
43 | "start-server-webpack-plugin": "^2.2.5",
44 | "ts-loader": "^7.0.4",
45 | "ts-node": "^8.0.2",
46 | "ts-node-dev": "^1.1.6",
47 | "typescript": "^4.3.4",
48 | "webpack": "^4.43.0",
49 | "webpack-node-externals": "^1.7.2"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/examples/src/all-exception.filter.ts:
--------------------------------------------------------------------------------
1 | import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';
2 | import { Request, Response } from 'express';
3 |
4 | @Catch()
5 | export class AllExceptionFilter implements ExceptionFilter {
6 | catch(exception: unknown, host: ArgumentsHost): void {
7 | console.log('Main exception handler');
8 | const ctx = host.switchToHttp();
9 | const response = ctx.getResponse();
10 | const request = ctx.getRequest();
11 |
12 | const status = (exception as any).status ?? 500;
13 | const message = (exception as any).message ?? '';
14 |
15 | response.status(status).json({
16 | statusCode: status,
17 | timestamp: new Date().toISOString(),
18 | path: request.url,
19 | message,
20 | });
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/examples/src/bin/runner.ts:
--------------------------------------------------------------------------------
1 | import { NestFactory } from '@nestjs/core';
2 | import { AllExceptionFilter } from '../all-exception.filter';
3 | import { EventStoreHeroesModule } from '../heroes/event-store-heroes.module';
4 | import { INestApplication } from '@nestjs/common';
5 |
6 | async function bootstrap() {
7 | const app: INestApplication = await NestFactory.create(
8 | EventStoreHeroesModule,
9 | {
10 | logger: ['log', 'error', 'warn', 'debug', 'verbose'],
11 | },
12 | );
13 |
14 | app.useGlobalFilters(new AllExceptionFilter());
15 | await app.listen(3000, () => {
16 | console.log('Application is listening on port 3000.');
17 | });
18 | }
19 |
20 | process.once('uncaughtException', (e: Error) => {
21 | if (e['code'] !== 'ERR_STREAM_WRITE_AFTER_END') {
22 | throw e;
23 | }
24 | });
25 | bootstrap();
26 |
--------------------------------------------------------------------------------
/examples/src/heroes/aggregates/hero.aggregate.ts:
--------------------------------------------------------------------------------
1 | import { HeroFoundItemEvent } from '../events/impl/hero-found-item.event';
2 | import { HeroKilledDragonEvent } from '../events/impl/hero-killed-dragon.event';
3 | import { HeroDropItemEvent } from '../events/impl/hero-drop-item.event';
4 | import { HeroDamagedEnemyEvent } from '../events/impl/hero-damaged-enemy.event';
5 | import { EventStoreAggregateRoot } from '../../../../src';
6 |
7 | export class Hero extends EventStoreAggregateRoot {
8 | constructor(private readonly id) {
9 | super();
10 | // comment this line to test correlation-id auto-generated stream
11 | this.streamName = `hero-${id}`;
12 | }
13 |
14 | damageEnemy(dragonId: string, hitPoint: number) {
15 | return this.apply(
16 | new HeroDamagedEnemyEvent({
17 | heroId: this.id,
18 | // comment dragonId if you want to test validation,
19 | dragonId,
20 | hitPoint: hitPoint,
21 | } as any),
22 | );
23 | }
24 |
25 | killEnemy(dragonId: string) {
26 | // logic
27 | return this.apply(
28 | new HeroKilledDragonEvent({
29 | heroId: this.id,
30 | dragonId,
31 | }),
32 | );
33 | }
34 |
35 | addItem(itemId: string) {
36 | // logic
37 | return this.apply(
38 | new HeroFoundItemEvent({
39 | heroId: this.id,
40 | itemId,
41 | }),
42 | );
43 | }
44 |
45 | dropItem(itemId: string) {
46 | return this.apply(
47 | new HeroDropItemEvent({
48 | heroId: this.id,
49 | itemId,
50 | }),
51 | );
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/examples/src/heroes/commands/handlers/drop-ancient-item.handler.ts:
--------------------------------------------------------------------------------
1 | import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
2 | import * as clc from 'cli-color';
3 | import { HeroRepository } from '../../repository/hero.repository';
4 | import { DropAncientItemCommand } from '../impl/drop-ancient-item.command';
5 | import { WriteEventBus } from '../../../../../src/';
6 |
7 | @CommandHandler(DropAncientItemCommand)
8 | export class DropAncientItemHandler
9 | implements ICommandHandler
10 | {
11 | constructor(
12 | private readonly repository: HeroRepository,
13 | private readonly publisher: WriteEventBus,
14 | ) {}
15 |
16 | async execute(command: DropAncientItemCommand) {
17 | console.log(clc.yellowBright('Async DropAncientItemCommand...'));
18 |
19 | const { heroId, itemId } = command;
20 | const hero = await this.repository.findOneById(+heroId);
21 | hero.autoCommit = true;
22 | hero.maxAge = 600; // 10 min
23 | hero.addPublisher((events, context) =>
24 | this.publisher.publishAll(events, context),
25 | );
26 | await hero.addItem(itemId);
27 | await hero.dropItem(itemId);
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/examples/src/heroes/commands/handlers/index.ts:
--------------------------------------------------------------------------------
1 | import { KillDragonHandler } from './kill-dragon.handler';
2 | import { DropAncientItemHandler } from './drop-ancient-item.handler';
3 |
4 | export const CommandHandlers = [KillDragonHandler, DropAncientItemHandler];
5 |
--------------------------------------------------------------------------------
/examples/src/heroes/commands/handlers/kill-dragon.handler.ts:
--------------------------------------------------------------------------------
1 | import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
2 | import * as clc from 'cli-color';
3 | import { HeroRepository } from '../../repository/hero.repository';
4 | import { KillDragonCommand } from '../impl/kill-dragon.command';
5 | import { WriteEventBus } from 'nestjs-geteventstore';
6 | import * as constants from '@eventstore/db-client/dist/constants';
7 | import { Hero } from '../../aggregates/hero.aggregate';
8 |
9 | @CommandHandler(KillDragonCommand)
10 | export class KillDragonHandler implements ICommandHandler {
11 | constructor(
12 | private readonly repository: HeroRepository,
13 | private readonly publisher: WriteEventBus,
14 | ) {}
15 |
16 | async execute(command: KillDragonCommand): Promise {
17 | const { heroId, dragonId } = command;
18 |
19 | console.log(
20 | clc.greenBright(
21 | `KillDragonCommand... for hero ${heroId} on enemy ${dragonId}`,
22 | ),
23 | );
24 | // build aggregate by fetching data from database && add publisher
25 | // const hero = (await this.repository.findOneById(+heroId)).addPublisher(
26 | // this.publisher.publishAll.bind(this.publisher),
27 | // );
28 | const hero: Hero = (
29 | await this.repository.findOneById(+heroId)
30 | ).addPublisher(this.publisher);
31 |
32 | await hero.damageEnemy(dragonId, 2);
33 | await hero.damageEnemy(dragonId, -8);
34 | await hero.damageEnemy(dragonId, 10);
35 | await hero.damageEnemy(dragonId, 10);
36 | await hero.damageEnemy(dragonId, -1);
37 | await hero.damageEnemy(dragonId, 10);
38 | await hero.damageEnemy(dragonId, 10);
39 | await hero.damageEnemy(dragonId, 10);
40 | await hero.damageEnemy(dragonId, 10);
41 | // await hero.commit(constants.ANY);
42 | await hero.killEnemy(dragonId);
43 | await hero.commit(constants.ANY);
44 |
45 | return command;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/examples/src/heroes/commands/impl/drop-ancient-item.command.ts:
--------------------------------------------------------------------------------
1 | export class DropAncientItemCommand {
2 | constructor(public readonly heroId: string, public readonly itemId: string) {}
3 | }
4 |
--------------------------------------------------------------------------------
/examples/src/heroes/commands/impl/kill-dragon.command.ts:
--------------------------------------------------------------------------------
1 | export class KillDragonCommand {
2 | constructor(
3 | public readonly heroId: string,
4 | public readonly dragonId: string,
5 | ) {}
6 | }
7 |
--------------------------------------------------------------------------------
/examples/src/heroes/dto/hero-damaged-enemy.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
2 |
3 | export class HeroDamagedEnemyDto {
4 | @IsNotEmpty()
5 | @IsString()
6 | heroId: string;
7 |
8 | @IsNotEmpty()
9 | @IsString()
10 | dragonId: string;
11 |
12 | @IsNumber()
13 | hitPoint: number;
14 | }
15 |
--------------------------------------------------------------------------------
/examples/src/heroes/event-store-heroes.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { TerminusModule } from '@nestjs/terminus';
3 | import { ContextModule } from 'nestjs-context';
4 | import { LoggerModule } from 'nestjs-pino-stackdriver/dist';
5 | import { resolve } from 'path';
6 |
7 | import { CommandHandlers } from './commands/handlers';
8 | import { EventHandlers } from './events/handlers';
9 | import { heroesEvents } from './events/impl';
10 | import { HealthController } from './health.controller';
11 | import { HeroesGameController } from './heroes.controller';
12 | import { QueryHandlers } from './queries/handlers';
13 | import { HeroRepository } from './repository/hero.repository';
14 | import { HeroesGameSagas } from './sagas/heroes.sagas';
15 | import {
16 | CqrsEventStoreModule,
17 | EventBusConfigType,
18 | EventStoreConnectionConfig,
19 | IEventStoreSubsystems,
20 | } from 'nestjs-geteventstore';
21 |
22 | const eventStoreConnectionConfig: EventStoreConnectionConfig = {
23 | connectionSettings: {
24 | connectionString:
25 | process.env.CONNECTION_STRING || 'esdb://localhost:2113?tls=false',
26 | },
27 | defaultUserCredentials: {
28 | username: process.env.EVENTSTORE_CREDENTIALS_USERNAME || 'admin',
29 | password: process.env.EVENTSTORE_CREDENTIALS_PASSWORD || 'changeit',
30 | },
31 | };
32 |
33 | const eventStoreSubsystems: IEventStoreSubsystems = {
34 | subscriptions: {
35 | persistent: [
36 | {
37 | // Event stream category (before the -)
38 | stream: '$ce-hero',
39 | group: 'data',
40 | settingsForCreation: {
41 | subscriptionSettings: {
42 | resolveLinkTos: true,
43 | minCheckpointCount: 1,
44 | },
45 | },
46 | onError: (err: Error) =>
47 | console.log(`An error occurred : ${err.message}`),
48 | },
49 | ],
50 | },
51 | projections: [
52 | {
53 | name: 'hero-dragon',
54 | file: resolve(`${__dirname}/projections/hero-dragon.js`),
55 | mode: 'continuous',
56 | enabled: true,
57 | checkPointsEnabled: true,
58 | emitEnabled: true,
59 | },
60 | ],
61 | onConnectionFail: (err: Error) =>
62 | console.log(`Connection to Event store hooked : ${err}`),
63 | };
64 |
65 | const eventBusConfig: EventBusConfigType = {
66 | read: {
67 | allowedEvents: { ...heroesEvents },
68 | },
69 | write: {
70 | serviceName: 'test',
71 | },
72 | };
73 |
74 | @Module({
75 | controllers: [HealthController, HeroesGameController],
76 | providers: [
77 | HeroRepository,
78 | ...CommandHandlers,
79 | ...EventHandlers,
80 | ...QueryHandlers,
81 | HeroesGameSagas,
82 | ],
83 | imports: [
84 | ContextModule.register(),
85 | TerminusModule,
86 | LoggerModule.forRoot(),
87 | CqrsEventStoreModule.register(
88 | eventStoreConnectionConfig,
89 | eventStoreSubsystems,
90 | eventBusConfig,
91 | ),
92 | ],
93 | })
94 | export class EventStoreHeroesModule {}
95 |
--------------------------------------------------------------------------------
/examples/src/heroes/events/handlers/hero-found-item.handler.ts:
--------------------------------------------------------------------------------
1 | import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
2 | import * as clc from 'cli-color';
3 | import { HeroFoundItemEvent } from '../impl/hero-found-item.event';
4 |
5 | @EventsHandler(HeroFoundItemEvent)
6 | export class HeroFoundItemHandler implements IEventHandler {
7 | handle(event: HeroFoundItemEvent) {
8 | console.log(clc.yellowBright('Async HeroFoundItemEventHandler...'));
9 | //throw new Error(`Error handling ${event.constructor.name}`);
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/examples/src/heroes/events/handlers/hero-killed-dragon.handler.ts:
--------------------------------------------------------------------------------
1 | import { IEventHandler } from '@nestjs/cqrs';
2 | import { EventsHandler } from '@nestjs/cqrs/dist/decorators/events-handler.decorator';
3 | import * as clc from 'cli-color';
4 | import { HeroKilledDragonEvent } from '../impl/hero-killed-dragon.event';
5 |
6 | @EventsHandler(HeroKilledDragonEvent)
7 | export class HeroKilledDragonHandler
8 | implements IEventHandler {
9 | async handle(event: HeroKilledDragonEvent) {
10 | console.log(clc.greenBright('HeroKilledDragonEventHandler...'));
11 | await event.ack();
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/examples/src/heroes/events/handlers/index.ts:
--------------------------------------------------------------------------------
1 | import { HeroKilledDragonHandler } from './hero-killed-dragon.handler';
2 | import { HeroFoundItemHandler } from './hero-found-item.handler';
3 |
4 | export const EventHandlers = [HeroKilledDragonHandler, HeroFoundItemHandler];
5 |
--------------------------------------------------------------------------------
/examples/src/heroes/events/impl/hero-damaged-enemy.event.ts:
--------------------------------------------------------------------------------
1 | import { ValidateNested } from 'class-validator';
2 | import { Type } from 'class-transformer';
3 | import { EventStoreEvent } from '../../../../../src';
4 | import { HeroDamagedEnemyDto } from '../../dto/hero-damaged-enemy.dto';
5 |
6 | export class HeroDamagedEnemyEvent extends EventStoreEvent {
7 | @ValidateNested()
8 | @Type(() => HeroDamagedEnemyDto)
9 | public declare data: HeroDamagedEnemyDto;
10 | }
11 |
--------------------------------------------------------------------------------
/examples/src/heroes/events/impl/hero-drop-item.event.ts:
--------------------------------------------------------------------------------
1 | import { EventStoreEvent } from '../../../../../src';
2 |
3 | export class HeroDropItemEvent extends EventStoreEvent {
4 | constructor(
5 | public readonly data: {
6 | heroId: string;
7 | itemId: string;
8 | },
9 | options?,
10 | ) {
11 | super(data, options);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/examples/src/heroes/events/impl/hero-found-item.event.ts:
--------------------------------------------------------------------------------
1 | import { EventStoreEvent } from '../../../../../src';
2 |
3 | export class HeroFoundItemEvent extends EventStoreEvent {
4 | constructor(
5 | public readonly data: {
6 | heroId: string;
7 | itemId: string;
8 | },
9 | options?,
10 | ) {
11 | super(data, options);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/examples/src/heroes/events/impl/hero-killed-dragon.event.ts:
--------------------------------------------------------------------------------
1 | import {
2 | EventStoreAcknowledgeableEvent,
3 | EventVersion,
4 | } from 'nestjs-geteventstore';
5 |
6 | // This is the second version of this event
7 | @EventVersion(2)
8 | export class HeroKilledDragonEvent extends EventStoreAcknowledgeableEvent {
9 | public declare readonly data: {
10 | heroId: string;
11 | dragonId: string;
12 | };
13 | }
14 |
--------------------------------------------------------------------------------
/examples/src/heroes/events/impl/index.ts:
--------------------------------------------------------------------------------
1 | import { HeroFoundItemEvent } from './hero-found-item.event';
2 | import { HeroKilledDragonEvent } from './hero-killed-dragon.event';
3 | import { HeroDropItemEvent } from './hero-drop-item.event';
4 | import { HeroDamagedEnemyEvent } from './hero-damaged-enemy.event';
5 |
6 | export const heroesEvents = {
7 | HeroDamagedEnemyEvent,
8 | HeroKilledDragonEvent,
9 | HeroFoundItemEvent,
10 | HeroDropItemEvent,
11 | };
12 |
--------------------------------------------------------------------------------
/examples/src/heroes/health.controller.ts:
--------------------------------------------------------------------------------
1 | import { HealthCheck, HealthIndicatorResult } from '@nestjs/terminus';
2 | import { Controller, Get } from '@nestjs/common';
3 | import { EventStoreHealthIndicator } from '../../../src';
4 |
5 | @Controller('health')
6 | export class HealthController {
7 | constructor(
8 | private readonly eventStoreHealthIndicator: EventStoreHealthIndicator,
9 | ) {}
10 |
11 | @Get()
12 | @HealthCheck()
13 | public async healthCheck(): Promise {
14 | return this.eventStoreHealthIndicator.check();
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/examples/src/heroes/heroes.controller.ts:
--------------------------------------------------------------------------------
1 | import { Body, Controller, Get, Param, Put } from '@nestjs/common';
2 | import { CommandBus, QueryBus } from '@nestjs/cqrs';
3 | import { KillDragonCommand } from './commands/impl/kill-dragon.command';
4 | import { KillDragonDto } from './interfaces/kill-dragon-dto.interface';
5 | import { Hero } from './aggregates/hero.aggregate';
6 | import { GetHeroesQuery } from './queries/impl';
7 |
8 | @Controller('hero')
9 | export class HeroesGameController {
10 | constructor(
11 | private readonly commandBus: CommandBus,
12 | private readonly queryBus: QueryBus,
13 | ) {}
14 |
15 | @Put(':id/kill')
16 | async killDragon(@Param('id') id: string, @Body() dto: KillDragonDto) {
17 | return this.commandBus
18 | .execute(new KillDragonCommand(id, dto.dragonId))
19 | .catch((e) => console.log('e : ', e));
20 | }
21 |
22 | @Get()
23 | async findAll(): Promise {
24 | return this.queryBus.execute(new GetHeroesQuery());
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/examples/src/heroes/heroes.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { CqrsModule } from '@nestjs/cqrs';
3 | import { CommandHandlers } from './commands/handlers';
4 | import { EventHandlers } from './events/handlers';
5 | import { HeroesGameController } from './heroes.controller';
6 | import { QueryHandlers } from './queries/handlers';
7 | import { HeroRepository } from './repository/hero.repository';
8 | import { HeroesGameSagas } from './sagas/heroes.sagas';
9 |
10 | @Module({
11 | imports: [CqrsModule],
12 | controllers: [HeroesGameController],
13 | providers: [
14 | HeroRepository,
15 | ...CommandHandlers,
16 | ...EventHandlers,
17 | ...QueryHandlers,
18 | HeroesGameSagas,
19 | ],
20 | })
21 | export class HeroesGameModule {}
22 |
--------------------------------------------------------------------------------
/examples/src/heroes/interfaces/kill-dragon-dto.interface.ts:
--------------------------------------------------------------------------------
1 | export interface KillDragonDto {
2 | dragonId: string;
3 | }
4 |
--------------------------------------------------------------------------------
/examples/src/heroes/projections/hero-dragon.js:
--------------------------------------------------------------------------------
1 | fromCategory('$et-hero')
2 | .partitionBy((ev) => ev.data.dragonId)
3 | .when({
4 | HeroKilledDragonEvent: (state, event) => {
5 | emit(`dragon-${event.data.dragonId}`, 'KilledEvent', event.data, {
6 | specversion: event.metadata.specversion,
7 | type: event.metadata.type.replace(
8 | 'HeroKilledDragonEvent',
9 | 'KilledEvent',
10 | ),
11 | source: event.metadata.source,
12 | correlation_id: event.metadata.correlation_id,
13 | time: event.metadata.time,
14 | version: 1,
15 | });
16 | return state;
17 | },
18 | });
19 |
--------------------------------------------------------------------------------
/examples/src/heroes/queries/handlers/get-heroes.handler.ts:
--------------------------------------------------------------------------------
1 | import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
2 | import * as clc from 'cli-color';
3 | import { HeroRepository } from '../../repository/hero.repository';
4 | import { GetHeroesQuery } from '../impl';
5 |
6 | @QueryHandler(GetHeroesQuery)
7 | export class GetHeroesHandler implements IQueryHandler {
8 | constructor(private readonly repository: HeroRepository) {}
9 |
10 | async execute(query: GetHeroesQuery) {
11 | console.log(clc.yellowBright('Async GetHeroesQuery...'));
12 | return this.repository.findAll();
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/examples/src/heroes/queries/handlers/index.ts:
--------------------------------------------------------------------------------
1 | import { GetHeroesHandler } from './get-heroes.handler';
2 |
3 | export const QueryHandlers = [GetHeroesHandler];
4 |
--------------------------------------------------------------------------------
/examples/src/heroes/queries/impl/get-heroes.query.ts:
--------------------------------------------------------------------------------
1 | export class GetHeroesQuery {}
2 |
--------------------------------------------------------------------------------
/examples/src/heroes/queries/impl/index.ts:
--------------------------------------------------------------------------------
1 | export * from './get-heroes.query';
2 |
--------------------------------------------------------------------------------
/examples/src/heroes/repository/fixtures/user.ts:
--------------------------------------------------------------------------------
1 | import { Hero } from '../../aggregates/hero.aggregate';
2 |
3 | export const userHero = new Hero('greg');
4 |
--------------------------------------------------------------------------------
/examples/src/heroes/repository/hero.repository.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { Hero } from '../aggregates/hero.aggregate';
3 | import { userHero } from './fixtures/user';
4 |
5 | @Injectable()
6 | export class HeroRepository {
7 | async findOneById(id: number): Promise {
8 | return new Hero(id);
9 | }
10 |
11 | async findAll(): Promise {
12 | return [userHero];
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/examples/src/heroes/sagas/heroes.sagas.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { ICommand, Saga } from '@nestjs/cqrs';
3 | import * as clc from 'cli-color';
4 | import { v4 } from 'uuid';
5 | import { Context, CONTEXT_CORRELATION_ID } from 'nestjs-context';
6 | import { Observable } from 'rxjs';
7 | import { delay, filter, map } from 'rxjs/operators';
8 | import { DropAncientItemCommand } from '../commands/impl/drop-ancient-item.command';
9 | import { HeroKilledDragonEvent } from '../events/impl/hero-killed-dragon.event';
10 |
11 | const itemId = '0';
12 |
13 | @Injectable()
14 | export class HeroesGameSagas {
15 | constructor(private readonly context: Context) {}
16 | @Saga()
17 | dragonKilled = (events$: Observable): Observable => {
18 | return events$.pipe(
19 | //@ts-ignore
20 | filter((ev) => ev instanceof HeroKilledDragonEvent),
21 | //@ts-ignore
22 | delay(400),
23 | //@ts-ignore
24 | map((event: HeroKilledDragonEvent) => {
25 | this.context.setCachedValue(
26 | CONTEXT_CORRELATION_ID,
27 | event?.metadata?.correlation_id || v4(),
28 | );
29 | console.log(
30 | clc.redBright('Inside [HeroesGameSagas] Saga after a little sleep'),
31 | );
32 | console.log(event);
33 | return new DropAncientItemCommand(event.data.heroId, itemId);
34 | }),
35 | );
36 | };
37 | }
38 |
--------------------------------------------------------------------------------
/examples/src/heroes/write.controller.ts:
--------------------------------------------------------------------------------
1 | import { Body, Controller, Param, Put } from '@nestjs/common';
2 | import { CommandBus } from '@nestjs/cqrs';
3 | import { KillDragonCommand } from './commands/impl/kill-dragon.command';
4 | import { KillDragonDto } from './interfaces/kill-dragon-dto.interface';
5 |
6 | @Controller('hero')
7 | export class WriteController {
8 | constructor(private readonly commandBus: CommandBus) {}
9 |
10 | @Put(':id/kill')
11 | async killDragon(@Param('id') id: string, @Body() dto: KillDragonDto) {
12 | return this.commandBus.execute(new KillDragonCommand(id, dto.dragonId));
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/examples/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/examples/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./",
4 | "module": "commonjs",
5 | "declaration": false,
6 | "noImplicitAny": false,
7 | "removeComments": true,
8 | "noLib": false,
9 | "emitDecoratorMetadata": true,
10 | "experimentalDecorators": true,
11 | "strictPropertyInitialization": false,
12 | "useDefineForClassFields": true,
13 | "target": "ES2020",
14 | "sourceMap": true,
15 | "allowJs": true,
16 | "outDir": "./dist",
17 | "composite": false
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/examples/webpack-hmr.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 | const nodeExternals = require('webpack-node-externals');
3 | const StartServerPlugin = require('start-server-webpack-plugin');
4 |
5 | module.exports = function(options) {
6 | return {
7 | ...options,
8 | entry: ['webpack/hot/poll?100', options.entry],
9 | watch: true,
10 | externals: [
11 | nodeExternals({
12 | whitelist: ['webpack/hot/poll?100'],
13 | }),
14 | ],
15 | plugins: [
16 | ...options.plugins,
17 | new webpack.HotModuleReplacementPlugin(),
18 | new webpack.WatchIgnorePlugin([/\.js$/, /\.d\.ts$/]),
19 | new StartServerPlugin({ name: options.output.filename }),
20 | ],
21 | };
22 | };
--------------------------------------------------------------------------------
/index.d.ts:
--------------------------------------------------------------------------------
1 | export * from './dist';
2 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | function __export(m) {
3 | for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p];
4 | }
5 | exports.__esModule = true;
6 | __export(require("./dist"));
7 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | export * from './dist';
2 |
--------------------------------------------------------------------------------
/jest.setup.ts:
--------------------------------------------------------------------------------
1 | export default (): void => {
2 | console.log = () => {
3 | // do nothing
4 | };
5 | console.debug = () => {
6 | // do nothing
7 | };
8 | console.error = () => {
9 | // do nothing
10 | };
11 | };
12 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nestjs-geteventstore",
3 | "version": "5.0.19",
4 | "description": "Event Store connector for NestJS-Cqrs",
5 | "author": "Vincent Vermersch ",
6 | "contributors": [
7 | "vinceveve",
8 | "jdharandas",
9 | "monocursive",
10 | "xGouley",
11 | "jokesterfr",
12 | "MaxencePerrinPrestashop",
13 | "prxmat",
14 | "maniolias"
15 | ],
16 | "license": "MIT",
17 | "readmeFilename": "README.md",
18 | "main": "dist/index.js",
19 | "scripts": {
20 | "start:dev": "tsc -w",
21 | "build": "tsc",
22 | "prepare": "npm run build && husky install",
23 | "format": "prettier --write \"src/**/*.ts\"",
24 | "lint": "eslint .",
25 | "check-lite": "npm run lint:fix && npm run prepare",
26 | "test": "jest",
27 | "semantic-release": "semantic-release",
28 | "test:watch": "jest --watch",
29 | "test:cov": "jest --coverage",
30 | "test:e2e": "jest --config ./test/jest-e2e.json"
31 | },
32 | "keywords": [
33 | "nestjs",
34 | "eventstore"
35 | ],
36 | "repository": "git@github.com:PrestaShopCorp/nestjs-eventstore.git",
37 | "publishConfig": {
38 | "access": "public"
39 | },
40 | "bugs": "https://github.com/prestashopCorp/nestjs-eventstore/issues",
41 | "peerDependencies": {
42 | "@nestjs/common": "*",
43 | "@nestjs/core": "*",
44 | "@nestjs/cqrs": "*",
45 | "@nestjs/terminus": "*",
46 | "class-transformer": "*",
47 | "class-validator": "*",
48 | "nestjs-context": "^0.12.0",
49 | "reflect-metadata": "^0.1.13",
50 | "rimraf": "^3.0.2",
51 | "rxjs": "^6.6.3"
52 | },
53 | "peerDependenciesMeta": {
54 | "@nesjs/cqrs": {
55 | "optional": true
56 | }
57 | },
58 | "dependencies": {
59 | "@eventstore/db-client": "^2.1.0",
60 | "lodash": "^4.17.20",
61 | "uuid": "^8.3.2"
62 | },
63 | "devDependencies": {
64 | "@nestjs/common": "^7.6.15",
65 | "@nestjs/core": "^7.6.15",
66 | "@nestjs/cqrs": "^7.0.1",
67 | "@nestjs/platform-express": "^7.6.11",
68 | "@nestjs/terminus": "^7.1.2",
69 | "@nestjs/testing": "^7.6.11",
70 | "@types/jest": "^26.0.20",
71 | "@types/node": "^14.14.25",
72 | "@types/supertest": "^2.0.10",
73 | "@typescript-eslint/eslint-plugin": "^4.28.4",
74 | "@typescript-eslint/parser": "^4.28.4",
75 | "class-transformer": "^0.4.0",
76 | "class-validator": "^0.13.1",
77 | "eslint": "^7.31.0",
78 | "eslint-plugin-import": "^2.23.4",
79 | "eslint-plugin-jest": "^24.4.0",
80 | "eslint-plugin-local": "^1.0.0",
81 | "husky": "^7.0.1",
82 | "jest": "^26.6.3",
83 | "lint-staged": "^11.1.1",
84 | "nestjs-context": "^0.11.0",
85 | "prettier": "^2.2.1",
86 | "pretty-quick": "^3.1.1",
87 | "reflect-metadata": "^0.1.13",
88 | "rxjs": "^7.0.0",
89 | "supertest": "6.1.3",
90 | "ts-jest": "^26.5.0",
91 | "ts-node": "^9.1.1",
92 | "tsc-watch": "^4.2.9",
93 | "tsconfig-paths": "^3.9.0",
94 | "typescript": "^4.3.4"
95 | },
96 | "jest": {
97 | "moduleFileExtensions": [
98 | "js",
99 | "json",
100 | "ts"
101 | ],
102 | "rootDir": "./",
103 | "testRegex": ".spec.ts$",
104 | "transform": {
105 | "^.+\\.(t|j)s$": "ts-jest"
106 | },
107 | "globals": {
108 | "ts-jest": {
109 | "tsconfig": "./tsconfig.spec.json"
110 | }
111 | },
112 | "globalSetup": "./jest.setup.ts",
113 | "coverageDirectory": "../coverage",
114 | "testEnvironment": "node"
115 | },
116 | "lint-staged": {
117 | "./src/**/*.{ts}": [
118 | "eslint . --fix",
119 | "git add"
120 | ],
121 | "examples/src/**/*.{ts}": [
122 | "eslint . --fix",
123 | "git add"
124 | ]
125 | },
126 | "husky": {
127 | "hooks": {
128 | "pre-commit": "yarn lint-staged && yarn pretty-quick --staged && yarn jest --forceExit"
129 | }
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/src/cloudevents/index.ts:
--------------------------------------------------------------------------------
1 | export * from './write-events-prepublish.service';
2 |
--------------------------------------------------------------------------------
/src/cloudevents/write-events-prepublish.service.ts:
--------------------------------------------------------------------------------
1 | import { Logger, Injectable, Inject } from '@nestjs/common';
2 | import { plainToClass } from 'class-transformer';
3 | import { validate } from 'class-validator';
4 | import {
5 | Context,
6 | CONTEXT_BIN,
7 | CONTEXT_CORRELATION_ID,
8 | CONTEXT_HOSTNAME,
9 | CONTEXT_PATH,
10 | } from 'nestjs-context';
11 | import {
12 | IBaseEvent,
13 | IEventBusPrepublishPrepareProvider,
14 | IEventBusPrepublishValidateProvider,
15 | IWriteEventBusConfig,
16 | } from '../interfaces';
17 | import { EventStoreEvent } from '../event-store/events';
18 | import { EventMetadataDto } from '../dto';
19 | import { WRITE_EVENT_BUS_CONFIG } from '../constants';
20 | import { createEventDefaultMetadata } from '../tools/create-event-default-metadata';
21 | import { isIPv4 } from 'net';
22 |
23 | @Injectable()
24 | export class WriteEventsPrepublishService<
25 | T extends IBaseEvent = EventStoreEvent,
26 | > implements
27 | IEventBusPrepublishValidateProvider,
28 | IEventBusPrepublishPrepareProvider
29 | {
30 | private readonly logger = new Logger(this.constructor.name);
31 | constructor(
32 | private readonly context: Context,
33 | @Inject(WRITE_EVENT_BUS_CONFIG)
34 | private readonly config: IWriteEventBusConfig,
35 | ) {}
36 | // errors log
37 | async onValidationFail(events: T[], errors: any[]) {
38 | for (const error of errors) {
39 | this.logger.error(error);
40 | }
41 | }
42 |
43 | // transform to dto each event and validate it
44 | async validate(events: T[]) {
45 | let errors = [];
46 | for (const event of events) {
47 | this.logger.debug(`Validating ${event.constructor.name}`);
48 | // @todo JDM class-transformer is not converting data property !
49 | // (metadata is working, so it might be related to inheritance)
50 | const validateEvent: any = plainToClass(event.constructor as any, event);
51 | errors = [...errors, ...(await validate(validateEvent))];
52 | }
53 | return errors;
54 | }
55 |
56 | private getCloudEventMetadata(event: T): EventMetadataDto {
57 | try {
58 | const { version: defaultVersion, time } = createEventDefaultMetadata();
59 | const version = event?.metadata?.version ?? defaultVersion;
60 | const hostnameRaw = this.context.get(CONTEXT_HOSTNAME);
61 | const hostname = isIPv4(hostnameRaw)
62 | ? `${hostnameRaw.split(/[.]/).join('-')}.ip`
63 | : hostnameRaw;
64 | const hostnameArr = hostname.split('.');
65 | const eventType = `${hostnameArr[1] ? hostnameArr[1] + '.' : ''}${
66 | hostnameArr[0]
67 | }.${this.config.serviceName ?? this.context.get(CONTEXT_BIN)}.${
68 | event.eventType
69 | }.${version}`;
70 | const source = `${hostname}${this.context.get(CONTEXT_PATH)}`;
71 | return {
72 | specversion: 1,
73 | time,
74 | version,
75 | correlation_id: this.context.get(CONTEXT_CORRELATION_ID),
76 | type: eventType,
77 | source,
78 | created_at: new Date().toISOString(),
79 | };
80 | } catch (e) {
81 | this.logger.error(e);
82 | throw e;
83 | }
84 | }
85 |
86 | // add cloud events metadata
87 | async prepare(events: T[]) {
88 | const preparedEvents = [];
89 | for (const event of events) {
90 | this.logger.debug(`Preparing ${event.constructor.name}`);
91 | const preparedEvent = event;
92 | preparedEvent.metadata = {
93 | ...this.getCloudEventMetadata(event),
94 | ...(event.metadata ?? {}),
95 | };
96 | preparedEvents.push(preparedEvent);
97 | }
98 | return preparedEvents;
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const WRITE_EVENT_BUS_CONFIG = Symbol();
2 | export const READ_EVENT_BUS_CONFIG = Symbol();
3 | export const EVENT_STORE_SUBSYSTEMS = Symbol();
4 |
--------------------------------------------------------------------------------
/src/cqrs-event-store.module.ts:
--------------------------------------------------------------------------------
1 | import { CommandBus, CqrsModule, EventBus, QueryBus } from '@nestjs/cqrs';
2 | import { DynamicModule, Module } from '@nestjs/common';
3 |
4 | import { EventStoreModule } from './event-store/event-store.module';
5 | import {
6 | EventBusConfigType,
7 | IWriteEventBusConfig,
8 | ReadEventBusConfigType,
9 | } from './interfaces';
10 | import { ReadEventBus, WriteEventBus } from './cqrs';
11 | import { READ_EVENT_BUS_CONFIG, WRITE_EVENT_BUS_CONFIG } from './constants';
12 | import { EventBusPrepublishService } from './cqrs/event-bus-prepublish.service';
13 | import { WriteEventsPrepublishService } from './cloudevents';
14 | import { ContextName } from 'nestjs-context';
15 | import { IEventStoreSubsystems } from './event-store/config';
16 | import { EventStoreConnectionConfig } from './event-store/config/event-store-connection-config';
17 | import { ExplorerService } from '@nestjs/cqrs/dist/services/explorer.service';
18 | import { IPersistentSubscriptionConfig } from './event-store';
19 |
20 | const getDefaultEventBusConfiguration: IWriteEventBusConfig = {
21 | context: ContextName.HTTP,
22 | validate: WriteEventsPrepublishService,
23 | prepare: WriteEventsPrepublishService,
24 | };
25 |
26 | @Module({
27 | providers: [
28 | WriteEventsPrepublishService,
29 | EventBusPrepublishService,
30 | ExplorerService,
31 | WriteEventBus,
32 | ReadEventBus,
33 | CommandBus,
34 | QueryBus,
35 | { provide: EventBus, useExisting: ReadEventBus },
36 | ],
37 | exports: [
38 | WriteEventsPrepublishService,
39 | EventBusPrepublishService,
40 | ExplorerService,
41 | WriteEventBus,
42 | ReadEventBus,
43 | CommandBus,
44 | QueryBus,
45 | EventBus,
46 | ],
47 | })
48 | export class CqrsEventStoreModule extends CqrsModule {
49 | static register(
50 | eventStoreConfig: EventStoreConnectionConfig,
51 | eventStoreSubsystems: IEventStoreSubsystems = {
52 | onConnectionFail: (e) => console.log('e : ', e),
53 | },
54 | eventBusConfig: EventBusConfigType = {},
55 | ): DynamicModule {
56 | return {
57 | module: CqrsEventStoreModule,
58 | imports: [
59 | EventStoreModule.register(eventStoreConfig, eventStoreSubsystems),
60 | ],
61 | providers: [
62 | { provide: READ_EVENT_BUS_CONFIG, useValue: eventBusConfig.read },
63 | {
64 | provide: WRITE_EVENT_BUS_CONFIG,
65 | useValue: { ...getDefaultEventBusConfiguration, ...eventBusConfig },
66 | },
67 | ],
68 | exports: [EventStoreModule],
69 | };
70 | }
71 |
72 | static registerReadBus(
73 | eventStoreConfig: EventStoreConnectionConfig,
74 | eventBusConfig: ReadEventBusConfigType,
75 | subscriptions: IPersistentSubscriptionConfig[] = [],
76 | ): DynamicModule {
77 | return {
78 | module: CqrsEventStoreModule,
79 | imports: [
80 | EventStoreModule.register(eventStoreConfig, {
81 | subscriptions: { persistent: subscriptions },
82 | onConnectionFail: (e) => console.log('e : ', e),
83 | }),
84 | ],
85 | providers: [
86 | { provide: READ_EVENT_BUS_CONFIG, useValue: eventBusConfig },
87 | { provide: EventBus, useExisting: ReadEventBus },
88 | ],
89 | exports: [EventStoreModule],
90 | };
91 | }
92 |
93 | static registerWriteBus(
94 | eventStoreConfig: EventStoreConnectionConfig,
95 | eventBusConfig: IWriteEventBusConfig = {},
96 | ): DynamicModule {
97 | return {
98 | module: CqrsEventStoreModule,
99 | imports: [EventStoreModule.register(eventStoreConfig)],
100 | providers: [
101 | {
102 | provide: WRITE_EVENT_BUS_CONFIG,
103 | useValue: { ...getDefaultEventBusConfiguration, ...eventBusConfig },
104 | },
105 | ],
106 | exports: [EventStoreModule],
107 | };
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/src/cqrs/aggregate-root.ts:
--------------------------------------------------------------------------------
1 | import { Logger } from '@nestjs/common';
2 | import { IEvent } from '@nestjs/cqrs';
3 | import { InvalidPublisherException } from '../exceptions/invalid-publisher.exception';
4 |
5 | const INTERNAL_EVENTS = Symbol();
6 | const IS_AUTO_COMMIT_ENABLED = Symbol();
7 |
8 | export abstract class AggregateRoot {
9 | protected logger = new Logger(this.constructor.name);
10 | public [IS_AUTO_COMMIT_ENABLED] = false;
11 | private readonly [INTERNAL_EVENTS]: EventBase[] = [];
12 | private readonly _publishers: Function[] = [];
13 |
14 | set autoCommit(value: boolean) {
15 | this[IS_AUTO_COMMIT_ENABLED] = value;
16 | }
17 |
18 | get autoCommit(): boolean {
19 | return this[IS_AUTO_COMMIT_ENABLED];
20 | }
21 |
22 | addPublisher(
23 | publisher: T,
24 | method: keyof T = 'publishAll' as keyof T,
25 | ) {
26 | const objectPublisher = publisher?.[method];
27 | const addedPublisher =
28 | !!objectPublisher && typeof objectPublisher === 'function'
29 | ? objectPublisher.bind(publisher)
30 | : publisher;
31 | if (typeof addedPublisher === 'function') {
32 | this._publishers.push(addedPublisher);
33 | return this;
34 | }
35 | throw new InvalidPublisherException(publisher, method);
36 | }
37 |
38 | get publishers() {
39 | return this._publishers;
40 | }
41 |
42 | protected addEvent(event: T) {
43 | this[INTERNAL_EVENTS].push(event);
44 | return this;
45 | }
46 |
47 | protected clearEvents() {
48 | this[INTERNAL_EVENTS].length = 0;
49 | return this;
50 | }
51 |
52 | async commit() {
53 | this.logger.debug(
54 | `Aggregate will commit ${this.getUncommittedEvents().length} in ${
55 | this.publishers.length
56 | } publishers`,
57 | );
58 |
59 | // flush the queue first to avoid multiple commit of the same event on concurrent calls
60 | const events = this.getUncommittedEvents();
61 | this.clearEvents();
62 |
63 | // publish the event
64 | for (const publisher of this.publishers) {
65 | await publisher(events).catch((error) => {
66 | this[INTERNAL_EVENTS].unshift(...events);
67 | throw error;
68 | });
69 | }
70 | return this;
71 | }
72 |
73 | uncommit() {
74 | this.clearEvents();
75 | return this;
76 | }
77 |
78 | getUncommittedEvents(): EventBase[] {
79 | return this[INTERNAL_EVENTS];
80 | }
81 |
82 | loadFromHistory(history: EventBase[]) {
83 | history.forEach((event) => this.apply(event, true));
84 | }
85 |
86 | async apply(
87 | event: T,
88 | isFromHistory = false,
89 | ) {
90 | this.logger.debug(
91 | `Applying ${event.constructor.name} with${
92 | this.autoCommit ? '' : 'out'
93 | } autocommit`,
94 | );
95 | if (!isFromHistory) {
96 | this.addEvent(event);
97 | }
98 | // eslint-disable-next-line no-unused-expressions
99 | this.autoCommit && (await this.commit());
100 | const handler = this.getEventHandler(event);
101 | // eslint-disable-next-line no-unused-expressions
102 | handler && (await handler.call(this, event));
103 | }
104 |
105 | private getEventHandler(
106 | event: T,
107 | ): Function | undefined {
108 | const handler = `on${AggregateRoot.getEventName(event)}`;
109 | return this[handler];
110 | }
111 |
112 | private static getEventName(event: any): string {
113 | const { constructor } = Object.getPrototypeOf(event);
114 | return constructor.name as string;
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/cqrs/default-event-mapper.ts:
--------------------------------------------------------------------------------
1 | import { Logger } from '@nestjs/common';
2 | import { ReadEventOptionsType, ReadEventBusConfigType } from '../interfaces';
3 |
4 | export const defaultEventMapper = (
5 | allEvents: ReadEventBusConfigType['allowedEvents'],
6 | ) => {
7 | const logger = new Logger('Default Event Mapper');
8 | return ((data, options: ReadEventOptionsType) => {
9 | const className = `${options.eventType}`;
10 | if (allEvents[className]) {
11 | logger.log(
12 | `Build ${className} received from stream ${options.eventStreamId} with id ${options.eventId} and number ${options.eventNumber}`,
13 | );
14 | return new allEvents[className](data, options);
15 | }
16 | return null;
17 | }) as ReadEventBusConfigType['eventMapper'];
18 | };
19 |
--------------------------------------------------------------------------------
/src/cqrs/event-bus-prepublish.service.ts:
--------------------------------------------------------------------------------
1 | import { ModuleRef } from '@nestjs/core';
2 | import { Injectable } from '@nestjs/common';
3 | import {
4 | EventBusPrepublishPrepareCallbackType,
5 | IBaseEvent,
6 | IEventBusPrepublishConfig,
7 | IEventBusPrepublishPrepareProvider,
8 | IEventBusPrepublishValidateProvider,
9 | } from '../interfaces';
10 |
11 | @Injectable()
12 | export class EventBusPrepublishService<
13 | EventBase extends IBaseEvent = IBaseEvent,
14 | > {
15 | constructor(private readonly moduleRef: ModuleRef) {}
16 |
17 | private async getProvider<
18 | T =
19 | | IEventBusPrepublishPrepareProvider
20 | | IEventBusPrepublishValidateProvider,
21 | >(name): Promise {
22 | try {
23 | return await this.moduleRef.resolve(name);
24 | } catch (e) {
25 | return undefined;
26 | }
27 | }
28 |
29 | async validate(
30 | config: IEventBusPrepublishConfig,
31 | events: T[],
32 | ): Promise {
33 | const { validate } = config;
34 | if (!validate) {
35 | return [];
36 | }
37 | const validator =
38 | (await this.getProvider>(
39 | validate,
40 | )) ?? (validate as IEventBusPrepublishValidateProvider);
41 | const validated = await validator.validate(events);
42 | // validation passed without errors
43 | if (!validated.length) {
44 | return [];
45 | }
46 | // validation failed
47 | if (validator.onValidationFail) {
48 | await validator.onValidationFail(events, validated);
49 | }
50 | return validated;
51 | }
52 |
53 | async prepare(
54 | config: IEventBusPrepublishConfig,
55 | events: T[],
56 | ): Promise {
57 | const { prepare } = config;
58 | if (!prepare) {
59 | return events;
60 | }
61 | const provider = await this.getProvider<
62 | IEventBusPrepublishPrepareProvider
63 | >(prepare);
64 | return provider
65 | ? provider.prepare(events)
66 | : (prepare as EventBusPrepublishPrepareCallbackType)(events);
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/cqrs/index.ts:
--------------------------------------------------------------------------------
1 | export * from './aggregate-root';
2 | export * from './default-event-mapper';
3 | export * from './event-bus-prepublish.service';
4 | export * from './read-event-bus';
5 | export * from './write-event-bus';
6 |
--------------------------------------------------------------------------------
/src/cqrs/read-event-bus.ts:
--------------------------------------------------------------------------------
1 | import { CommandBus, EventBus as Parent } from '@nestjs/cqrs';
2 | import { Injectable, Logger } from '@nestjs/common';
3 | import {
4 | ReadEventOptionsType,
5 | IReadEvent,
6 | ReadEventBusConfigType,
7 | } from '../interfaces';
8 | import { defaultEventMapper } from './default-event-mapper';
9 | import { Inject } from '@nestjs/common';
10 | import { READ_EVENT_BUS_CONFIG } from '../constants';
11 | import { ModuleRef } from '@nestjs/core';
12 | import { EventBusPrepublishService } from './event-bus-prepublish.service';
13 |
14 | @Injectable()
15 | export class ReadEventBus<
16 | EventBase extends IReadEvent = IReadEvent
17 | > extends Parent {
18 | private logger = new Logger(this.constructor.name);
19 | constructor(
20 | @Inject(READ_EVENT_BUS_CONFIG)
21 | private readonly config: ReadEventBusConfigType,
22 | private readonly prepublish: EventBusPrepublishService,
23 | commandBus: CommandBus,
24 | moduleRef: ModuleRef,
25 | ) {
26 | super(commandBus, moduleRef);
27 | this.logger.debug('Registering Read EventBus for EventStore...');
28 | }
29 | async publish(event: T) {
30 | this.logger.debug('Publish in read bus');
31 | const preparedEvents = await this.prepublish.prepare(this.config, [event]);
32 | if (!(await this.prepublish.validate(this.config, preparedEvents))) {
33 | return;
34 | }
35 | return super.publish(preparedEvents[0]);
36 | }
37 | async publishAll(events: T[]) {
38 | this.logger.debug('Publish all in read bus');
39 | const preparedEvents = await this.prepublish.prepare(this.config, events);
40 | if (!(await this.prepublish.validate(this.config, preparedEvents))) {
41 | return;
42 | }
43 | return super.publishAll(preparedEvents);
44 | }
45 | map(data: any, options: ReadEventOptionsType): T {
46 | const eventMapper =
47 | this.config.eventMapper || defaultEventMapper(this.config.allowedEvents);
48 | return eventMapper(data, options) as T;
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/cqrs/write-event-bus.ts:
--------------------------------------------------------------------------------
1 | import { CommandBus, EventBus as Parent } from '@nestjs/cqrs';
2 | import { Inject, Injectable, Logger } from '@nestjs/common';
3 | import { EventStorePublisher } from '../event-store';
4 | import {
5 | IWriteEvent,
6 | IWriteEventBusConfig,
7 | PublicationContextInterface,
8 | } from '../interfaces';
9 | import { WRITE_EVENT_BUS_CONFIG } from '../constants';
10 | import { ModuleRef } from '@nestjs/core';
11 | import { EventBusPrepublishService } from './event-bus-prepublish.service';
12 | import { InvalidEventException } from '../exceptions/invalid-event.exception';
13 | import {
14 | EVENT_STORE_SERVICE,
15 | IEventStoreService,
16 | } from '../event-store/services/event-store.service.interface';
17 |
18 | // add next, pass onError
19 |
20 | @Injectable()
21 | export class WriteEventBus<
22 | EventBase extends IWriteEvent = IWriteEvent,
23 | > extends Parent {
24 | private logger = new Logger(this.constructor.name);
25 | constructor(
26 | @Inject(EVENT_STORE_SERVICE)
27 | private readonly eventstoreService: IEventStoreService,
28 | @Inject(WRITE_EVENT_BUS_CONFIG)
29 | private readonly config: IWriteEventBusConfig,
30 | private readonly prepublish: EventBusPrepublishService,
31 | commandBus: CommandBus,
32 | moduleRef: ModuleRef,
33 | ) {
34 | super(commandBus, moduleRef);
35 | this.logger.debug('Registering Write EventBus for EventStore...');
36 | this.publisher = new EventStorePublisher(
37 | this.eventstoreService,
38 | this.config,
39 | );
40 | }
41 |
42 | async publish(
43 | event: T,
44 | context?: PublicationContextInterface,
45 | ): Promise {
46 | this.logger.debug('Publish in write bus');
47 | const preparedEvents = await this.prepublish.prepare(this.config, [event]);
48 | const validated = await this.prepublish.validate(
49 | this.config,
50 | preparedEvents,
51 | );
52 | if (validated.length) {
53 | throw new InvalidEventException(validated);
54 | }
55 | return await this.publisher.publish(
56 | preparedEvents,
57 | // @ts-ignore
58 | context,
59 | );
60 | }
61 | async publishAll(
62 | events: T[],
63 | context?: PublicationContextInterface,
64 | ): Promise {
65 | this.logger.debug('Publish All in write bus');
66 | const preparedEvents = await this.prepublish.prepare(this.config, events);
67 | const validated = await this.prepublish.validate(
68 | this.config,
69 | preparedEvents,
70 | );
71 | if (validated.length) {
72 | throw new InvalidEventException(validated);
73 | }
74 | return await this.publisher.publishAll(
75 | preparedEvents,
76 | // @ts-ignore
77 | context,
78 | );
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/decorators/event-version.decorator.ts:
--------------------------------------------------------------------------------
1 | import { IBaseEvent } from '../interfaces';
2 |
3 | export const EventVersion = (version: number) => <
4 | T extends { new (...args: any[]): IBaseEvent }
5 | >(
6 | BaseEvent: T,
7 | ) => {
8 | const newClass = class extends BaseEvent implements IBaseEvent {
9 | constructor(...args: any[]) {
10 | super(...args);
11 | this.metadata.version = version;
12 | }
13 | };
14 | Object.defineProperty(newClass, 'name', {
15 | value: BaseEvent.name,
16 | });
17 | return newClass;
18 | };
19 |
--------------------------------------------------------------------------------
/src/decorators/index.ts:
--------------------------------------------------------------------------------
1 | export * from './event-version.decorator';
2 |
--------------------------------------------------------------------------------
/src/dto/event-metadata.dto.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Equals,
3 | IsNotEmpty,
4 | IsOptional,
5 | IsPositive,
6 | IsRFC3339,
7 | IsString,
8 | IsUrl,
9 | Matches,
10 | } from 'class-validator';
11 |
12 | /**
13 | * Event Store event metadata prepared to be transformed into cloudevents
14 | * @see https://github.com/cloudevents/spec/blob/v1.0.1/spec.md#overview
15 | */
16 | export class EventMetadataDto {
17 | // Cloud Event Metadata
18 | /**
19 | * Specification Version
20 | * @readonly
21 | * @see https://github.com/cloudevents/spec/blob/v1.0.1/spec.md#overview
22 | */
23 | @Equals(1)
24 | readonly specversion;
25 |
26 | /**
27 | * Timestamp of creation date
28 | * @example 1524379940
29 | */
30 | @IsRFC3339()
31 | time: string;
32 |
33 | /**
34 | * Typeof event. Note that "-" are not allowed, use "_" instead
35 | * ....
36 | * @example com.api.order.order_created.v2
37 | */
38 | @Matches(/(\w+\.){1,4}\w+/)
39 | type: string;
40 |
41 | /**
42 | * Identifier of the context in which an event happened, event source
43 | * An absolute URI is RECOMMENDED.
44 | * @example http://api-live.net/order/create
45 | * @example /order/create
46 | */
47 | @IsUrl({
48 | require_host: false,
49 | require_protocol: false,
50 | require_tld: false,
51 | allow_protocol_relative_urls: true,
52 | })
53 | source: string;
54 |
55 | /**
56 | * Identifier in source context sub-structure, if any
57 | */
58 | @IsOptional()
59 | @IsString()
60 | @IsNotEmpty()
61 | subject?: string;
62 |
63 | /**
64 | * @see RFC 2046
65 | */
66 | @IsOptional()
67 | @IsString()
68 | @IsNotEmpty()
69 | datacontenttype?: string;
70 |
71 | /**
72 | * Identifies the schema that data adheres to.
73 | * Incompatible changes to the schema SHOULD
74 | * be reflected by a different URI
75 | */
76 | @IsOptional()
77 | @IsUrl()
78 | dataschema?: string;
79 |
80 | // EventStore Specific (must be inside event-cloud data when transformed)
81 | /**
82 | * Event version
83 | * @example 1
84 | */
85 | @IsPositive()
86 | version: number;
87 |
88 | /**
89 | * Business process unique id
90 | * @example 15d5f8d5-869e-4107-9961-5035495fe416
91 | */
92 | @IsString()
93 | @IsNotEmpty()
94 | correlation_id: string;
95 |
96 | /**
97 | * The event creation date (IsoString formatted)
98 | * @example '2022-02-09T17:16:52.305Z'
99 | */
100 | @IsString()
101 | created_at: string;
102 | }
103 |
--------------------------------------------------------------------------------
/src/dto/index.ts:
--------------------------------------------------------------------------------
1 | export * from './event-metadata.dto';
2 | export * from './write-event.dto';
3 |
--------------------------------------------------------------------------------
/src/dto/write-event.dto.ts:
--------------------------------------------------------------------------------
1 | import { ValidateNested, IsNotEmpty, IsString } from 'class-validator';
2 | import { Type } from 'class-transformer';
3 | import { EventMetadataDto } from './event-metadata.dto';
4 |
5 | export class WriteEventDto {
6 | // TODO Vincent IsUuid ?
7 | @IsNotEmpty()
8 | @IsString()
9 | eventId: string;
10 |
11 | @IsNotEmpty()
12 | @IsString()
13 | eventType: string;
14 |
15 | @ValidateNested()
16 | @Type(() => EventMetadataDto)
17 | metadata: Partial; // we add partial to allow metadata auto-generation
18 |
19 | @ValidateNested()
20 | data: any;
21 | }
22 |
--------------------------------------------------------------------------------
/src/event-store/config/connector.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DNSClusterOptions,
3 | GossipClusterOptions,
4 | SingleNodeOptions,
5 | } from '@eventstore/db-client/dist/Client';
6 |
7 | export default interface Connector {
8 | connectionString?: string;
9 | OptionSettings?: SingleNodeOptions | DNSClusterOptions | GossipClusterOptions;
10 | }
11 |
--------------------------------------------------------------------------------
/src/event-store/config/event-store-connection-config.ts:
--------------------------------------------------------------------------------
1 | import { Credentials } from '@eventstore/db-client/dist/types';
2 | import { ChannelCredentialOptions } from '@eventstore/db-client/dist/Client';
3 | import Connector from './connector';
4 |
5 | export interface EventStoreConnectionConfig {
6 | connectionSettings: Connector;
7 | channelCredentials?: ChannelCredentialOptions;
8 | defaultUserCredentials?: Credentials;
9 | }
10 |
--------------------------------------------------------------------------------
/src/event-store/config/event-store-service-config.interface.ts:
--------------------------------------------------------------------------------
1 | import { EventStoreProjection } from '../../interfaces';
2 | import { IPersistentSubscriptionConfig } from '../subscriptions';
3 |
4 | export interface IEventStoreSubsystems {
5 | projections?: EventStoreProjection[];
6 | subscriptions?: {
7 | persistent?: IPersistentSubscriptionConfig[];
8 | };
9 | onEvent?: (sub, payload) => void;
10 | onConnectionFail?: (err: Error) => void;
11 | }
12 |
--------------------------------------------------------------------------------
/src/event-store/config/index.ts:
--------------------------------------------------------------------------------
1 | export * from './connector';
2 | export * from './event-store-connection-config';
3 | export * from './event-store-service-config.interface';
4 |
--------------------------------------------------------------------------------
/src/event-store/event-store-aggregate-root.ts:
--------------------------------------------------------------------------------
1 | import { AggregateRoot as Parent } from '../cqrs';
2 | import { IBaseEvent, PublicationContextInterface } from '../interfaces';
3 | import * as constants from '@eventstore/db-client/dist/constants';
4 | import { AppendExpectedRevision } from '@eventstore/db-client/dist/types';
5 | import { StreamMetadata } from '@eventstore/db-client/dist/utils/streamMetadata';
6 |
7 | export abstract class EventStoreAggregateRoot<
8 | EventBase extends IBaseEvent = IBaseEvent,
9 | > extends Parent {
10 | private _streamName?: string;
11 | private _streamMetadata?: StreamMetadata;
12 |
13 | set streamName(streamName: string) {
14 | this._streamName = streamName;
15 | }
16 |
17 | set streamMetadata(streamMetadata: StreamMetadata) {
18 | this._streamMetadata = streamMetadata;
19 | }
20 |
21 | set maxAge(maxAge: number) {
22 | this._streamMetadata = {
23 | ...this._streamMetadata,
24 | $maxAge: maxAge,
25 | };
26 | }
27 |
28 | set maxCount(maxCount: number) {
29 | this._streamMetadata = {
30 | ...this._streamMetadata,
31 | $maxCount: maxCount,
32 | };
33 | }
34 |
35 | public async commit(
36 | expectedRevision: AppendExpectedRevision = constants.ANY,
37 | expectedMetadataRevision: AppendExpectedRevision = constants.ANY,
38 | ) {
39 | this.logger.debug(
40 | `Aggregate will commit ${this.getUncommittedEvents().length} events in ${
41 | this.publishers.length
42 | } publishers`,
43 | );
44 | const context: PublicationContextInterface = {
45 | expectedRevision,
46 | ...(this._streamName ? { streamName: this._streamName } : {}),
47 | ...(this._streamMetadata
48 | ? { streamMetadata: this._streamMetadata, expectedMetadataRevision }
49 | : {}),
50 | };
51 | for (const publisher of this.publishers) {
52 | await publisher(this.getUncommittedEvents(), context);
53 | }
54 | this.clearEvents();
55 | return this;
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/event-store/event-store.module.ts:
--------------------------------------------------------------------------------
1 | import { DynamicModule, Module, Provider } from '@nestjs/common';
2 | import { EventStoreService } from './index';
3 | import { EventStoreHealthIndicator } from './health';
4 | import { EVENT_STORE_SUBSYSTEMS } from '../constants';
5 | import { IEventStoreSubsystems } from './config';
6 | import { EventStoreConnectionConfig } from './config/event-store-connection-config';
7 | import { EVENT_STORE_SERVICE } from './services/event-store.service.interface';
8 | import { Client } from '@eventstore/db-client/dist/Client';
9 | import { EventStoreDBClient } from '@eventstore/db-client';
10 | import { EVENT_STORE_CONNECTOR } from './services/event-store.constants';
11 | import { EVENTS_AND_METADATAS_STACKER } from './reliability/interface/events-and-metadatas-stacker';
12 | import InMemoryEventsAndMetadatasStacker from './reliability/implementations/in-memory/in-memory-events-and-metadatas-stacker';
13 |
14 | @Module({
15 | providers: [
16 | EventStoreHealthIndicator,
17 | {
18 | provide: EVENT_STORE_SERVICE,
19 | useClass: EventStoreService,
20 | },
21 | ],
22 | exports: [EVENT_STORE_SERVICE, EventStoreHealthIndicator],
23 | })
24 | export class EventStoreModule {
25 | static async register(
26 | config: EventStoreConnectionConfig,
27 | eventStoreSubsystems: IEventStoreSubsystems = {
28 | onConnectionFail: (e) => console.log('e : ', e),
29 | },
30 | ): Promise {
31 | return {
32 | module: EventStoreModule,
33 | providers: [
34 | {
35 | provide: EVENT_STORE_SUBSYSTEMS,
36 | useValue: eventStoreSubsystems,
37 | },
38 | {
39 | provide: EVENTS_AND_METADATAS_STACKER,
40 | useClass: InMemoryEventsAndMetadatasStacker,
41 | },
42 | await this.getEventStoreConnector(config),
43 | ],
44 | };
45 | }
46 |
47 | private static async getEventStoreConnector(
48 | config: EventStoreConnectionConfig,
49 | ): Promise {
50 | const eventStoreConnector: Client = EventStoreDBClient.connectionString(
51 | (config as EventStoreConnectionConfig).connectionSettings
52 | .connectionString,
53 | );
54 |
55 | return {
56 | provide: EVENT_STORE_CONNECTOR,
57 | useValue: eventStoreConnector,
58 | };
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/event-store/events/event-store-acknowledgeable.event.ts:
--------------------------------------------------------------------------------
1 | import { EventStoreEvent } from './index';
2 | import { IAcknowledgeableEvent } from '../../interfaces';
3 | import { PersistentSubscriptionNakEventAction } from '../../interfaces/events/persistent-subscription-nak-event-action.enum';
4 |
5 | export abstract class EventStoreAcknowledgeableEvent
6 | extends EventStoreEvent
7 | implements IAcknowledgeableEvent
8 | {
9 | ack() {
10 | return Promise.resolve();
11 | }
12 | nack(action: PersistentSubscriptionNakEventAction, reason: string) {
13 | return Promise.resolve();
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/event-store/events/event-store.event.ts:
--------------------------------------------------------------------------------
1 | import { v4 } from 'uuid';
2 |
3 | import { EventOptionsType, IReadEvent, IWriteEvent } from '../../interfaces';
4 | import { WriteEventDto } from '../../dto/write-event.dto';
5 |
6 | export abstract class EventStoreEvent
7 | extends WriteEventDto
8 | implements IWriteEvent, IReadEvent
9 | {
10 | // just for read events
11 | public readonly eventStreamId: IReadEvent['eventStreamId'] | undefined;
12 | public readonly eventNumber: IReadEvent['eventNumber'] | undefined;
13 | public readonly originalEventId: IReadEvent['originalEventId'] | undefined;
14 |
15 | constructor(public data: any, options?: EventOptionsType) {
16 | super();
17 | // metadata is added automatically in write events, so we cast to any
18 | this.metadata = options?.metadata || {};
19 | this.eventId = options?.eventId || v4();
20 | this.eventType = options?.eventType || this.constructor.name;
21 | this.eventStreamId = options?.eventStreamId ?? undefined;
22 | this.eventNumber = options?.eventNumber ?? undefined;
23 | this.originalEventId = options?.originalEventId ?? undefined;
24 | }
25 |
26 | // Notice we force this helpers to return strings
27 | // to keep string typing (!undefined) on our subscriptions
28 | getStream(): string {
29 | return this.eventStreamId || '';
30 | }
31 | getStreamCategory(): string {
32 | return this.eventStreamId?.split('-')[0] ?? '';
33 | }
34 | getStreamId(): string {
35 | return this.eventStreamId?.replace(/^[^-]*-/, '') ?? '';
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/event-store/events/index.ts:
--------------------------------------------------------------------------------
1 | export * from './event-store.event';
2 | export * from './event-store-acknowledgeable.event';
3 |
--------------------------------------------------------------------------------
/src/event-store/health/event-store-health.status.ts:
--------------------------------------------------------------------------------
1 | export default interface EventStoreHealthStatus {
2 | connection?: 'up' | 'down';
3 | subscriptions?: 'up' | 'down';
4 | }
5 |
--------------------------------------------------------------------------------
/src/event-store/health/event-store.health-indicator.spec.ts:
--------------------------------------------------------------------------------
1 | import { EventStoreHealthIndicator } from './event-store.health-indicator';
2 | import EventStoreHealthStatus from './event-store-health.status';
3 | import { HealthIndicatorResult } from '@nestjs/terminus';
4 | import { Logger as logger } from '@nestjs/common';
5 |
6 | describe('EventStoreHealthIndicator', () => {
7 | let service: EventStoreHealthIndicator;
8 |
9 | jest.mock('@nestjs/common');
10 | beforeEach(() => {
11 | service = new EventStoreHealthIndicator();
12 | jest.spyOn(logger, 'log').mockImplementation(() => null);
13 | jest.spyOn(logger, 'error').mockImplementation(() => null);
14 | jest.spyOn(logger, 'debug').mockImplementation(() => null);
15 | });
16 |
17 | it('should be created', () => {
18 | expect(service).toBeTruthy();
19 | });
20 |
21 | ['up', 'down'].forEach((status: 'up' | 'down') => {
22 | it(`should be notified when connection is ${status}`, () => {
23 | const esHealthStatus: EventStoreHealthStatus = {
24 | connection: status,
25 | };
26 | service.updateStatus(esHealthStatus);
27 |
28 | const check: HealthIndicatorResult = service.check();
29 |
30 | expect(check.connection.status).toEqual(status);
31 | });
32 | });
33 |
34 | ['up', 'down'].forEach((status: 'up' | 'down') => {
35 | it(`should be notified when subscription's connection is ${status}`, () => {
36 | const esHealthStatus: EventStoreHealthStatus = {
37 | subscriptions: status,
38 | };
39 | service.updateStatus(esHealthStatus);
40 |
41 | const check: HealthIndicatorResult = service.check();
42 |
43 | expect(check.subscriptions.status).toEqual(status);
44 | });
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/src/event-store/health/event-store.health-indicator.ts:
--------------------------------------------------------------------------------
1 | import { HealthIndicator, HealthIndicatorResult } from '@nestjs/terminus';
2 | import { Injectable } from '@nestjs/common';
3 | import EventStoreHealthStatus from './event-store-health.status';
4 |
5 | @Injectable()
6 | export class EventStoreHealthIndicator extends HealthIndicator {
7 | private esStatus: EventStoreHealthStatus;
8 |
9 | constructor() {
10 | super();
11 | }
12 |
13 | public check(): HealthIndicatorResult {
14 | return {
15 | connection: { status: this.esStatus.connection },
16 | subscriptions: { status: this.esStatus.subscriptions },
17 | };
18 | }
19 |
20 | public updateStatus(esHealthStatus: EventStoreHealthStatus): void {
21 | this.esStatus = { ...this.esStatus, ...esHealthStatus };
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/event-store/health/index.ts:
--------------------------------------------------------------------------------
1 | export * from './event-store.health-indicator';
2 | export * from './event-store-health.status';
3 |
--------------------------------------------------------------------------------
/src/event-store/index.ts:
--------------------------------------------------------------------------------
1 | export * from './config';
2 | export * from './events';
3 | export * from './health';
4 | export * from './publisher';
5 | export * from './reliability';
6 | export * from './services';
7 | export * from './subscriptions';
8 | export * from './event-store-aggregate-root';
9 | export * from './event-store.module';
10 |
--------------------------------------------------------------------------------
/src/event-store/publisher/event-store.publisher.spec.ts:
--------------------------------------------------------------------------------
1 | import { EventStorePublisher } from './event-store.publisher';
2 | import {
3 | IWriteEvent,
4 | IWriteEventBusConfig,
5 | PublicationContextInterface,
6 | } from '../../interfaces';
7 | import { of } from 'rxjs';
8 | import { EventStoreService } from '../services/event-store.service';
9 | import * as constants from '@eventstore/db-client/dist/constants';
10 | import { AppendExpectedRevision } from '@eventstore/db-client/dist/types';
11 | import { StreamMetadata } from '@eventstore/db-client/dist/utils/streamMetadata';
12 | import { Logger as logger } from '@nestjs/common';
13 | import InMemoryEventsAndMetadatasStacker from '../reliability/implementations/in-memory/in-memory-events-and-metadatas-stacker';
14 | import { Client } from '@eventstore/db-client/dist/Client';
15 | import { EventStoreHealthIndicator } from '../health';
16 | import spyOn = jest.spyOn;
17 |
18 | describe('EventStorePublisher', () => {
19 | let publisher: EventStorePublisher;
20 |
21 | let eventStore: Client;
22 | let eventStoreService: EventStoreService;
23 | let publisherConfig: IWriteEventBusConfig;
24 |
25 | const eventsStackerMock: InMemoryEventsAndMetadatasStacker = {
26 | putEventsInWaitingLine: jest.fn(),
27 | shiftEventsBatchFromWaitingLine: jest.fn(),
28 | getFirstOutFromEventsBatchesWaitingLine: jest.fn(),
29 | getEventBatchesWaitingLineLength: jest.fn(),
30 | putMetadatasInWaitingLine: jest.fn(),
31 | getFirstOutFromMetadatasWaitingLine: jest.fn(),
32 | shiftMetadatasFromWaitingLine: jest.fn(),
33 | getMetadatasWaitingLineLength: jest.fn(),
34 | } as unknown as InMemoryEventsAndMetadatasStacker;
35 |
36 | beforeEach(() => {
37 | jest.resetAllMocks();
38 | jest.mock('@nestjs/common');
39 | jest.spyOn(logger, 'log').mockImplementation(() => null);
40 | jest.spyOn(logger, 'error').mockImplementation(() => null);
41 | jest.spyOn(logger, 'debug').mockImplementation(() => null);
42 |
43 | publisherConfig = {};
44 | eventStore = {
45 | appendToStream: jest.fn(),
46 | setStreamMetadata: jest.fn(),
47 | } as unknown as Client;
48 |
49 | const eventStoreHealthIndicatorMock: EventStoreHealthIndicator = {
50 | updateStatus: jest.fn(),
51 | check: jest.fn(),
52 | } as unknown as EventStoreHealthIndicator;
53 |
54 | eventStoreService = new EventStoreService(
55 | eventStore,
56 | {
57 | onConnectionFail: () => {},
58 | },
59 | eventsStackerMock,
60 | eventStoreHealthIndicatorMock,
61 | );
62 | publisher = new EventStorePublisher(
63 | eventStoreService,
64 | publisherConfig,
65 | );
66 | });
67 |
68 | it('should be instanciated properly', () => {
69 | expect(publisher).toBeTruthy();
70 | });
71 |
72 | it('should give default context value when write events and no context given', async () => {
73 | spyOn(eventStore, 'appendToStream').mockImplementationOnce(() => {
74 | return null;
75 | });
76 | spyOn(eventStoreService, 'writeEvents');
77 | await eventStoreService.onModuleInit();
78 | await publisher.publish({
79 | data: undefined,
80 | metadata: {
81 | correlation_id: 'toto',
82 | },
83 | });
84 | expect(eventStoreService.writeEvents).toHaveBeenCalledWith(
85 | expect.anything(),
86 | expect.anything(),
87 | { expectedRevision: constants.ANY },
88 | );
89 | });
90 |
91 | it('should write metadatas when metadata stream is given', async () => {
92 | spyOn(eventStore, 'setStreamMetadata');
93 | spyOn(eventStoreService, 'writeMetadata');
94 |
95 | const streamName = 'streamName';
96 | const streamMetadata: StreamMetadata = { truncateBefore: 'start' };
97 | const expectedRevision: AppendExpectedRevision = constants.STREAM_EXISTS;
98 | const context: PublicationContextInterface = {
99 | streamName: streamName,
100 | expectedRevision: constants.ANY,
101 | streamMetadata,
102 | options: { expectedRevision },
103 | };
104 |
105 | await publisher.publish(
106 | {
107 | data: undefined,
108 | metadata: {
109 | correlation_id: 'toto',
110 | },
111 | },
112 | context,
113 | );
114 |
115 | expect(eventStoreService.writeMetadata).toHaveBeenCalledWith(
116 | streamName,
117 | streamMetadata,
118 | context.options,
119 | );
120 | });
121 |
122 | it('should publish single event the same way than multiple events when only 1 event is ', async () => {
123 | eventStore.appendToStream = jest.fn().mockReturnValue(of({}));
124 | spyOn(publisher, 'publishAll');
125 | await publisher.publish({
126 | data: undefined,
127 | metadata: {
128 | correlation_id: 'toto',
129 | },
130 | });
131 | expect(publisher.publishAll).toHaveBeenCalled();
132 | });
133 | });
134 |
--------------------------------------------------------------------------------
/src/event-store/publisher/event-store.publisher.ts:
--------------------------------------------------------------------------------
1 | import { hostname } from 'os';
2 | import { IEventPublisher } from '@nestjs/cqrs';
3 | import { Inject, Logger } from '@nestjs/common';
4 | import { basename, extname } from 'path';
5 |
6 | import {
7 | IWriteEvent,
8 | IWriteEventBusConfig,
9 | PublicationContextInterface,
10 | } from '../../interfaces';
11 | import {
12 | EVENT_STORE_SERVICE,
13 | IEventStoreService,
14 | } from '../services/event-store.service.interface';
15 | import { AppendResult } from '@eventstore/db-client/dist/types';
16 | import { EventData } from '@eventstore/db-client/dist/types/events';
17 | import { jsonEvent } from '@eventstore/db-client';
18 | import * as constants from '@eventstore/db-client/dist/constants';
19 |
20 | export class EventStorePublisher
21 | implements IEventPublisher
22 | {
23 | private logger: Logger = new Logger(this.constructor.name);
24 |
25 | constructor(
26 | @Inject(EVENT_STORE_SERVICE)
27 | private readonly eventStoreService: IEventStoreService,
28 | private readonly config: IWriteEventBusConfig,
29 | ) {}
30 |
31 | private async writeEvents(
32 | events: T[],
33 | context: PublicationContextInterface = {},
34 | ): Promise {
35 | const {
36 | streamName = context?.streamName ||
37 | this.getStreamName(events[0].metadata.correlation_id),
38 | expectedRevision,
39 | streamMetadata,
40 | options,
41 | } = context;
42 | if (streamMetadata) {
43 | await this.eventStoreService.writeMetadata(
44 | streamName,
45 | streamMetadata,
46 | options,
47 | );
48 | }
49 | const eventCount = events.length;
50 | this.logger.debug(
51 | `Write ${eventCount} events to stream ${streamName} with expectedVersion ${expectedRevision}`,
52 | );
53 | return this.eventStoreService.writeEvents(
54 | streamName,
55 | events.map((event: T): EventData => {
56 | return jsonEvent({
57 | id: event.eventId,
58 | type: event.eventType,
59 | metadata: event.metadata,
60 | data: event.data,
61 | });
62 | }),
63 | {
64 | expectedRevision: expectedRevision ?? constants.ANY,
65 | },
66 | );
67 | }
68 |
69 | protected getStreamName(
70 | correlationId: EventBase['metadata']['correlation_id'],
71 | ): string {
72 | const defaultName = process.argv?.[1]
73 | ? basename(process.argv?.[1], extname(process.argv?.[1]))
74 | : `${hostname()}_${process.argv?.[0] || 'unknown'}`;
75 |
76 | return `${this.config.serviceName || defaultName}-${correlationId}`;
77 | }
78 |
79 | async publish(
80 | event: T,
81 | context?: PublicationContextInterface,
82 | ): Promise {
83 | return this.publishAll([event], context);
84 | }
85 |
86 | async publishAll(
87 | events: T[],
88 | context?: PublicationContextInterface,
89 | ): Promise {
90 | return await this.writeEvents(events, context);
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/event-store/publisher/index.ts:
--------------------------------------------------------------------------------
1 | export * from './event-store.publisher';
2 |
--------------------------------------------------------------------------------
/src/event-store/reliability/implementations/in-memory/in-memory-events-and-metadatas-stacker.spec.ts:
--------------------------------------------------------------------------------
1 | import InMemoryEventsAndMetadatasStacker from './in-memory-events-and-metadatas-stacker';
2 | import EventBatch from '../../interface/event-batch';
3 | import { EventData } from '@eventstore/db-client/dist/types/events';
4 | import MetadatasContextDatas from '../../interface/metadatas-context-datas';
5 | import { AppendToStreamOptions } from '@eventstore/db-client/dist/streams';
6 | import { ANY } from '@eventstore/db-client';
7 | import { Logger as logger } from '@nestjs/common';
8 |
9 | describe('InMemoryEventsAndMetadatasStacker', () => {
10 | let service: InMemoryEventsAndMetadatasStacker;
11 |
12 | jest.mock('@nestjs/common');
13 | beforeEach(() => {
14 | jest.spyOn(logger, 'log').mockImplementation(() => null);
15 | jest.spyOn(logger, 'error').mockImplementation(() => null);
16 | jest.spyOn(logger, 'debug').mockImplementation(() => null);
17 | service = new InMemoryEventsAndMetadatasStacker();
18 | });
19 |
20 | describe('when stacking events', () => {
21 | it('should be able to add a new element at the end of the fifo', () => {
22 | let event: EventData = getDumbEvent('1');
23 | let events: EventData[] = [event];
24 | let stream = 'poj';
25 | let expectedVersion: AppendToStreamOptions = { expectedRevision: ANY };
26 | const batch1: EventBatch = {
27 | events,
28 | stream,
29 | expectedVersion,
30 | };
31 | event = getDumbEvent('2');
32 | events = [event];
33 | stream = 'poj';
34 | expectedVersion = { expectedRevision: ANY };
35 | const batch2 = {
36 | events,
37 | stream,
38 | expectedVersion,
39 | };
40 |
41 | service.putEventsInWaitingLine(batch1);
42 | service.putEventsInWaitingLine(batch2);
43 |
44 | expect(
45 | service.getFirstOutFromEventsBatchesWaitingLine().events[0].id,
46 | ).toEqual('1');
47 | expect(
48 | service.getFirstOutFromEventsBatchesWaitingLine().events[0].id,
49 | ).not.toEqual('2');
50 | });
51 |
52 | it('should not fail when getting first out from waiting line and line is empty', () => {
53 | expect(service.getFirstOutFromEventsBatchesWaitingLine()).toBeNull();
54 | });
55 |
56 | it('should be able to give the fifo length ', () => {
57 | const batch1: EventBatch = getDumbBatch('1', 'poj');
58 | const batch2: EventBatch = getDumbBatch('2', 'oiu');
59 |
60 | service.putEventsInWaitingLine(batch1);
61 | service.putEventsInWaitingLine(batch2);
62 |
63 | expect(service.getEventBatchesWaitingLineLength()).toEqual(2);
64 | });
65 |
66 | it('should be able to remove the first element of the waiting line', () => {
67 | const batch1: EventBatch = getDumbBatch('1', 'poj');
68 | const batch2: EventBatch = getDumbBatch('2', 'oiu');
69 |
70 | service.putEventsInWaitingLine(batch1);
71 | service.putEventsInWaitingLine(batch2);
72 |
73 | const unstackedBatch1: EventBatch =
74 | service.shiftEventsBatchFromWaitingLine();
75 | const unstackedBatch2: EventBatch =
76 | service.shiftEventsBatchFromWaitingLine();
77 |
78 | expect(unstackedBatch1.stream).toEqual('poj');
79 | expect(unstackedBatch2.stream).toEqual('oiu');
80 | expect(service.getEventBatchesWaitingLineLength()).toEqual(0);
81 | });
82 | });
83 |
84 | describe('when stacking metadatas', () => {
85 | it('should be able to add a new element at the end of the fifo', () => {
86 | const metadatasContextDatas1: MetadatasContextDatas =
87 | getDumbMetadata('1');
88 | const metadatasContextDatas2: MetadatasContextDatas =
89 | getDumbMetadata('2');
90 |
91 | service.putMetadatasInWaitingLine(metadatasContextDatas1);
92 | service.putMetadatasInWaitingLine(metadatasContextDatas2);
93 |
94 | expect(service.getFirstOutFromMetadatasWaitingLine().streamName).toEqual(
95 | '1',
96 | );
97 | expect(
98 | service.getFirstOutFromMetadatasWaitingLine().streamName,
99 | ).not.toEqual('2');
100 | });
101 |
102 | it('should not fail when getting first out from waiting line and line is empty', () => {
103 | expect(service.getFirstOutFromMetadatasWaitingLine()).toBeNull();
104 | });
105 |
106 | it('should be able to give the fifo length ', () => {
107 | const metadatasContextDatas1: MetadatasContextDatas =
108 | getDumbMetadata('1');
109 | const metadatasContextDatas2: MetadatasContextDatas =
110 | getDumbMetadata('2');
111 |
112 | service.putMetadatasInWaitingLine(metadatasContextDatas1);
113 | service.putMetadatasInWaitingLine(metadatasContextDatas2);
114 |
115 | expect(service.getMetadatasWaitingLineLength()).toEqual(2);
116 | });
117 |
118 | it('should be able to remove the first element of the waiting line', () => {
119 | const metadatasContextDatas1: MetadatasContextDatas =
120 | getDumbMetadata('1');
121 | const metadatasContextDatas2: MetadatasContextDatas =
122 | getDumbMetadata('2');
123 |
124 | service.putMetadatasInWaitingLine(metadatasContextDatas1);
125 | service.putMetadatasInWaitingLine(metadatasContextDatas2);
126 |
127 | const metadataGot1 = service.shiftMetadatasFromWaitingLine();
128 | const metadataGot2 = service.shiftMetadatasFromWaitingLine();
129 |
130 | expect(metadataGot1.streamName).toEqual('1');
131 | expect(metadataGot2.streamName).toEqual('2');
132 | expect(service.getEventBatchesWaitingLineLength()).toEqual(0);
133 | });
134 | });
135 | });
136 |
137 | function getDumbBatch(eventId: string, stream: string): EventBatch {
138 | const events: EventData[] = [getDumbEvent(eventId)];
139 | const expectedVersion: AppendToStreamOptions = { expectedRevision: ANY };
140 | return {
141 | events,
142 | stream,
143 | expectedVersion,
144 | };
145 | }
146 |
147 | const getDumbEvent = (id?: string): EventData => {
148 | return {
149 | contentType: undefined,
150 | data: undefined,
151 | id: id ?? '',
152 | metadata: undefined,
153 | type: '',
154 | };
155 | };
156 | const getDumbMetadata = (streamName?: string): MetadatasContextDatas => {
157 | return {
158 | metadata: undefined,
159 | streamName: streamName ?? '',
160 | };
161 | };
162 |
--------------------------------------------------------------------------------
/src/event-store/reliability/implementations/in-memory/in-memory-events-and-metadatas-stacker.ts:
--------------------------------------------------------------------------------
1 | import IEventsAndMetadatasStacker from '../../interface/events-and-metadatas-stacker';
2 | import EventBatch from '../../interface/event-batch';
3 | import MetadatasContextDatas from '../../interface/metadatas-context-datas';
4 |
5 | export default class InMemoryEventsAndMetadatasStacker
6 | implements IEventsAndMetadatasStacker
7 | {
8 | private eventBatchesFifo: EventBatch[] = [];
9 |
10 | private metadatasFifo: MetadatasContextDatas[] = [];
11 |
12 | public shiftEventsBatchFromWaitingLine(): EventBatch {
13 | return this.eventBatchesFifo.shift();
14 | }
15 |
16 | public getFirstOutFromEventsBatchesWaitingLine(): EventBatch {
17 | if (this.eventBatchesFifo.length === 0) {
18 | return null;
19 | }
20 | return this.eventBatchesFifo[0];
21 | }
22 |
23 | public putEventsInWaitingLine(batch: EventBatch): void {
24 | this.eventBatchesFifo.push(batch);
25 | }
26 |
27 | public getEventBatchesWaitingLineLength(): number {
28 | return this.eventBatchesFifo.length;
29 | }
30 |
31 | public shiftMetadatasFromWaitingLine(): MetadatasContextDatas {
32 | return this.metadatasFifo.shift();
33 | }
34 |
35 | public getFirstOutFromMetadatasWaitingLine(): MetadatasContextDatas {
36 | if (this.metadatasFifo.length === 0) {
37 | return null;
38 | }
39 | return this.metadatasFifo[0];
40 | }
41 |
42 | public getMetadatasWaitingLineLength(): number {
43 | return this.metadatasFifo.length;
44 | }
45 |
46 | public putMetadatasInWaitingLine(metadata: MetadatasContextDatas): void {
47 | this.metadatasFifo.push(metadata);
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/event-store/reliability/index.ts:
--------------------------------------------------------------------------------
1 | export * from './implementations/in-memory/in-memory-events-and-metadatas-stacker';
2 | export * from './interface/event-batch';
3 | export * from './interface/events-and-metadatas-stacker';
4 | export * from './interface/metadatas-context-datas';
5 |
--------------------------------------------------------------------------------
/src/event-store/reliability/interface/event-batch.ts:
--------------------------------------------------------------------------------
1 | import { EventData } from '@eventstore/db-client/dist/types/events';
2 | import { AppendToStreamOptions } from '@eventstore/db-client/dist/streams';
3 |
4 | export default interface EventBatch {
5 | stream: string;
6 | events: EventData[];
7 | expectedVersion: AppendToStreamOptions;
8 | }
9 |
--------------------------------------------------------------------------------
/src/event-store/reliability/interface/events-and-metadatas-stacker.ts:
--------------------------------------------------------------------------------
1 | import EventBatch from './event-batch';
2 | import MetadatasContextDatas from './metadatas-context-datas';
3 |
4 | export const EVENTS_AND_METADATAS_STACKER = Symbol();
5 |
6 | export default interface IEventsAndMetadatasStacker {
7 | putEventsInWaitingLine(events: EventBatch): void;
8 |
9 | shiftEventsBatchFromWaitingLine(): EventBatch;
10 |
11 | getFirstOutFromEventsBatchesWaitingLine(): EventBatch;
12 |
13 | getEventBatchesWaitingLineLength(): number;
14 |
15 | putMetadatasInWaitingLine(metadata: MetadatasContextDatas): void;
16 |
17 | getFirstOutFromMetadatasWaitingLine(): MetadatasContextDatas;
18 |
19 | shiftMetadatasFromWaitingLine(): MetadatasContextDatas;
20 |
21 | getMetadatasWaitingLineLength(): number;
22 | }
23 |
--------------------------------------------------------------------------------
/src/event-store/reliability/interface/metadatas-context-datas.ts:
--------------------------------------------------------------------------------
1 | import { SetStreamMetadataOptions } from '@eventstore/db-client/dist/streams';
2 | import { StreamMetadata } from '@eventstore/db-client/dist/utils/streamMetadata';
3 |
4 | export default interface MetadatasContextDatas {
5 | streamName: string;
6 | metadata: StreamMetadata;
7 | options?: SetStreamMetadataOptions;
8 | }
9 |
--------------------------------------------------------------------------------
/src/event-store/services/errors.constant.ts:
--------------------------------------------------------------------------------
1 | export const PERSISTENT_SUBSCRIPTION_ALREADY_EXIST_ERROR_CODE = 6;
2 | export const PROJECTION_ALREADY_EXIST_ERROR_CODE = 2;
3 | export const RECONNECTION_TRY_DELAY_IN_MS = 1000;
4 |
--------------------------------------------------------------------------------
/src/event-store/services/event-store.constants.ts:
--------------------------------------------------------------------------------
1 | export const EVENT_STORE_CONNECTOR = Symbol();
2 |
--------------------------------------------------------------------------------
/src/event-store/services/event-store.service.interface.ts:
--------------------------------------------------------------------------------
1 | import { EventStoreProjection } from '../../interfaces';
2 | import {
3 | CreateContinuousProjectionOptions,
4 | CreateOneTimeProjectionOptions,
5 | CreateTransientProjectionOptions,
6 | GetProjectionStateOptions,
7 | } from '@eventstore/db-client/dist/projections';
8 | import { DeletePersistentSubscriptionOptions } from '@eventstore/db-client/dist/persistentSubscription';
9 | import { PersistentSubscriptionSettings } from '@eventstore/db-client/dist/utils';
10 | import {
11 | AppendResult,
12 | BaseOptions,
13 | Credentials,
14 | StreamingRead,
15 | } from '@eventstore/db-client/dist/types';
16 | import {
17 | AppendToStreamOptions,
18 | GetStreamMetadataResult,
19 | ReadStreamOptions,
20 | SetStreamMetadataOptions,
21 | } from '@eventstore/db-client/dist/streams';
22 | import { StreamMetadata } from '@eventstore/db-client/dist/utils/streamMetadata';
23 | import { ReadableOptions } from 'stream';
24 | import { PersistentSubscription, ResolvedEvent } from '@eventstore/db-client';
25 | import { EventData } from '@eventstore/db-client/dist/types/events';
26 | import { IPersistentSubscriptionConfig } from '../subscriptions';
27 |
28 | export const EVENT_STORE_SERVICE = Symbol();
29 |
30 | export interface IEventStoreService {
31 | createProjection(
32 | query: string,
33 | type: 'oneTime' | 'continuous' | 'transient',
34 | projectionName?: string,
35 | options?:
36 | | CreateContinuousProjectionOptions
37 | | CreateTransientProjectionOptions
38 | | CreateOneTimeProjectionOptions,
39 | ): Promise;
40 |
41 | getProjectionState(
42 | streamName: string,
43 | options?: GetProjectionStateOptions,
44 | ): Promise;
45 |
46 | updateProjection(
47 | projection: EventStoreProjection,
48 | content: string,
49 | ): Promise;
50 |
51 | upsertProjections(projections: EventStoreProjection[]): Promise;
52 |
53 | createPersistentSubscription(
54 | streamName: string,
55 | groupName: string,
56 | settings: Partial,
57 | options?: BaseOptions,
58 | ): Promise;
59 |
60 | updatePersistentSubscription(
61 | streamName: string,
62 | group: string,
63 | options: Partial,
64 | credentials?: Credentials,
65 | ): Promise;
66 |
67 | deletePersistentSubscription(
68 | streamName: string,
69 | groupName: string,
70 | options?: DeletePersistentSubscriptionOptions,
71 | ): Promise;
72 |
73 | subscribeToPersistentSubscriptions(
74 | subscriptions: IPersistentSubscriptionConfig[],
75 | ): Promise;
76 |
77 | getPersistentSubscriptions(): PersistentSubscription[];
78 |
79 | readMetadata(stream: string): Promise;
80 |
81 | writeMetadata(
82 | streamName: string,
83 | metadata: StreamMetadata,
84 | options?: SetStreamMetadataOptions,
85 | ): Promise;
86 |
87 | readFromStream(
88 | stream: string,
89 | options?: ReadStreamOptions,
90 | readableOptions?: ReadableOptions,
91 | ): Promise>;
92 |
93 | writeEvents(
94 | stream: string,
95 | events: EventData[],
96 | expectedVersion: AppendToStreamOptions,
97 | ): Promise;
98 | }
99 |
--------------------------------------------------------------------------------
/src/event-store/services/event.handler.helper.spec.ts:
--------------------------------------------------------------------------------
1 | import EventHandlerHelper from './event.handler.helper';
2 | import { Logger, Logger as logger } from '@nestjs/common';
3 |
4 | describe('EventHandlerHelper', () => {
5 | jest.mock('@nestjs/common');
6 | beforeEach(() => {
7 | jest.spyOn(logger, 'log').mockImplementation(() => null);
8 | jest.spyOn(logger, 'error').mockImplementation(() => null);
9 | jest.spyOn(logger, 'debug').mockImplementation(() => null);
10 | });
11 |
12 | it('should be callable', () => {
13 | const result = EventHandlerHelper.onEvent(
14 | logger as unknown as Logger,
15 | {},
16 | {},
17 | );
18 | expect(result).toBeTruthy();
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/src/event-store/services/event.handler.helper.ts:
--------------------------------------------------------------------------------
1 | import { IAcknowledgeableEvent } from '../../interfaces';
2 | import { Logger } from '@nestjs/common';
3 | import { PersistentSubscriptionNakEventAction } from '../../interfaces/events/persistent-subscription-nak-event-action.enum';
4 | import { ReadEventBus } from '../../cqrs';
5 |
6 | export default class EventHandlerHelper {
7 | public static async onEvent(
8 | logger: Logger,
9 | subscription: any,
10 | payload: any,
11 | eventBus?: ReadEventBus,
12 | ): Promise {
13 | // do nothing, as we have not defined an event bus
14 | if (!eventBus) {
15 | return;
16 | }
17 |
18 | // use default onEvent
19 | const { event } = payload;
20 | // TODO allow unresolved event
21 | if (!payload.isResolved) {
22 | logger.warn(
23 | `Ignore unresolved event from stream ${payload.originalStreamId} with ID ${payload.originalEvent.eventId}`,
24 | );
25 | if (!subscription._autoAck && subscription.hasOwnProperty('_autoAck')) {
26 | await subscription.acknowledge([payload]);
27 | }
28 | return;
29 | }
30 | // TODO handle not JSON
31 | if (!event.isJson) {
32 | // TODO add info on error not coded
33 | logger.warn(
34 | `Received event that could not be resolved! stream ${event.eventStreamId} type ${event.eventType} id ${event.eventId} `,
35 | );
36 | if (!subscription._autoAck && subscription.hasOwnProperty('_autoAck')) {
37 | await subscription.acknowledge([payload]);
38 | }
39 | return;
40 | }
41 |
42 | // TODO throw error
43 | let data = {};
44 | try {
45 | data = JSON.parse(event.data.toString());
46 | } catch (e) {
47 | logger.warn(
48 | `Received event of type ${event.eventType} with shitty data acknowledge`,
49 | );
50 | if (!subscription._autoAck && subscription.hasOwnProperty('_autoAck')) {
51 | await subscription.acknowledge([payload]);
52 | }
53 | return;
54 | }
55 |
56 | // we do not add default metadata as
57 | // we do not want to modify
58 | // read models
59 | let metadata = {};
60 | if (event.metadata.toString()) {
61 | metadata = { ...metadata, ...JSON.parse(event.metadata.toString()) };
62 | }
63 |
64 | const finalEvent = eventBus.map(data, {
65 | metadata,
66 | eventStreamId: event.eventStreamId,
67 | eventId: event.eventId,
68 | eventNumber: event.eventNumber.low,
69 | eventType: event.eventType,
70 | originalEventId: payload.originalEvent.eventId || event.eventId,
71 | });
72 |
73 | if (!finalEvent) {
74 | logger.warn(
75 | `Received event of type ${event.eventType} with no declared handler acknowledge`,
76 | );
77 | if (!subscription._autoAck && subscription.hasOwnProperty('_autoAck')) {
78 | await subscription.acknowledge([payload]);
79 | }
80 | return;
81 | }
82 | // If event wants to handle ack/nack
83 | // only for persistent
84 | if (subscription.hasOwnProperty('_autoAck')) {
85 | if (
86 | typeof finalEvent.ack == 'function' &&
87 | typeof finalEvent.nack == 'function'
88 | ) {
89 | const ack = async () => {
90 | logger.debug(
91 | `Acknowledge event ${event.eventType} with id ${event.eventId}`,
92 | );
93 | return subscription.acknowledge([payload]);
94 | };
95 | const nack = async (
96 | action: PersistentSubscriptionNakEventAction,
97 | reason: string,
98 | ) => {
99 | logger.debug(
100 | `Nak and ${
101 | Object.keys(PersistentSubscriptionNakEventAction)[action]
102 | } for event ${event.eventType} with id ${
103 | event.eventId
104 | } : reason ${reason}`,
105 | );
106 | return subscription.fail([payload], action, reason);
107 | };
108 |
109 | finalEvent.ack = ack;
110 | finalEvent.nack = nack;
111 | } else {
112 | // Otherwise manage here
113 | logger.debug(
114 | `Auto acknowledge event ${event.eventType} with id ${event.eventId}`,
115 | );
116 | subscription.acknowledge([payload]);
117 | }
118 | }
119 |
120 | // Dispatch to event handlers and sagas
121 | await eventBus.publish(finalEvent);
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/src/event-store/services/index.ts:
--------------------------------------------------------------------------------
1 | export * from './errors.constant';
2 | export * from './event-store.constants';
3 | export * from './event-store.service.interface';
4 | export * from './event-store.service';
5 | export * from './event.handler.helper';
6 |
--------------------------------------------------------------------------------
/src/event-store/subscriptions/index.ts:
--------------------------------------------------------------------------------
1 | export * from './persistent-subscription-config.interface';
2 |
--------------------------------------------------------------------------------
/src/event-store/subscriptions/persistent-subscription-config.interface.ts:
--------------------------------------------------------------------------------
1 | import { DuplexOptions } from 'stream';
2 | import { PersistentSubscriptionSettings } from '@eventstore/db-client/dist/utils';
3 | import { ConnectToPersistentSubscriptionOptions } from '@eventstore/db-client/dist/persistentSubscription';
4 | import { BaseOptions } from '@eventstore/db-client/dist/types';
5 |
6 | export interface IPersistentSubscriptionConfig {
7 | stream: string;
8 | group: string;
9 | optionsForConnection?: {
10 | subscriptionConnectionOptions?: Partial;
11 | duplexOptions?: Partial;
12 | };
13 | settingsForCreation?: {
14 | subscriptionSettings?: Partial;
15 | baseOptions?: Partial;
16 | };
17 |
18 | onSubscriptionStart?: () => void | undefined;
19 | onSubscriptionDropped?: (reason: string, error: string) => void | undefined;
20 | onError?: (error: Error) => void | undefined;
21 | }
22 |
--------------------------------------------------------------------------------
/src/exceptions/index.ts:
--------------------------------------------------------------------------------
1 | export * from './invalid-event.exception';
2 | export * from './invalid-publisher.exception';
3 |
--------------------------------------------------------------------------------
/src/exceptions/invalid-event.exception.ts:
--------------------------------------------------------------------------------
1 | import { HttpException, HttpStatus } from '@nestjs/common';
2 |
3 | export class InvalidEventException extends HttpException {
4 | constructor(errors: Error[]) {
5 | super(errors, HttpStatus.INTERNAL_SERVER_ERROR);
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/exceptions/invalid-publisher.exception.ts:
--------------------------------------------------------------------------------
1 | import { HttpException, HttpStatus } from '@nestjs/common';
2 |
3 | export class InvalidPublisherException<
4 | T extends object = Function
5 | > extends HttpException {
6 | constructor(publisher: T, method: keyof T) {
7 | super(
8 | `Invalid publisher: expected ${
9 | publisher.constructor.name + '::' + method
10 | } to be a function`,
11 | HttpStatus.INTERNAL_SERVER_ERROR,
12 | );
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from '@eventstore/db-client/dist/constants';
2 | export * from './cloudevents';
3 | export * from './cqrs';
4 | export * from './decorators';
5 | export * from './dto';
6 | export * from './event-store';
7 | export * from './exceptions';
8 | export * from './interfaces';
9 | export * from './tools';
10 |
11 | export * from './cqrs-event-store.module';
12 | export * from './constants';
13 |
--------------------------------------------------------------------------------
/src/interfaces/config/event-bus-config.type.ts:
--------------------------------------------------------------------------------
1 | import { ReadEventBusConfigType } from './read-event-bus-config.type';
2 | import { IWriteEventBusConfig } from './write-event-bus-config.interface';
3 |
4 | export type EventBusConfigType = {
5 | read?: ReadEventBusConfigType;
6 | write?: IWriteEventBusConfig;
7 | };
8 |
--------------------------------------------------------------------------------
/src/interfaces/config/event-bus-prepublish-config.interface.ts:
--------------------------------------------------------------------------------
1 | import { Type } from '@nestjs/common';
2 | import { IBaseEvent } from '../events';
3 | import { IEventBusPrepublishValidateProvider } from './event-bus-prepublish-validate-provider.interface';
4 | import { IEventBusPrepublishPrepareProvider } from './event-bus-prepublish-prepare-provider.interface';
5 | import { EventBusPrepublishPrepareCallbackType } from './event-bus-prepublish-prepare-callback.type';
6 |
7 | export interface IEventBusPrepublishConfig {
8 | validate?:
9 | | Type>
10 | | IEventBusPrepublishValidateProvider;
11 | prepare?:
12 | | Type>
13 | | EventBusPrepublishPrepareCallbackType;
14 | }
15 |
--------------------------------------------------------------------------------
/src/interfaces/config/event-bus-prepublish-prepare-callback.type.ts:
--------------------------------------------------------------------------------
1 | import { IBaseEvent } from '../events';
2 |
3 | export type EventBusPrepublishPrepareCallbackType<
4 | T extends IBaseEvent,
5 | K extends IBaseEvent = T
6 | > = (events: T[]) => Promise;
7 |
--------------------------------------------------------------------------------
/src/interfaces/config/event-bus-prepublish-prepare-provider.interface.ts:
--------------------------------------------------------------------------------
1 | import { IBaseEvent } from '../events';
2 | import { EventBusPrepublishPrepareCallbackType } from './event-bus-prepublish-prepare-callback.type';
3 |
4 | export interface IEventBusPrepublishPrepareProvider<
5 | T extends IBaseEvent,
6 | K extends IBaseEvent = T
7 | > {
8 | prepare: EventBusPrepublishPrepareCallbackType;
9 | }
10 |
--------------------------------------------------------------------------------
/src/interfaces/config/event-bus-prepublish-validate-provider.interface.ts:
--------------------------------------------------------------------------------
1 | import { IBaseEvent } from '../events';
2 |
3 | export interface IEventBusPrepublishValidateProvider {
4 | validate: (events: T[]) => Promise;
5 | onValidationFail: (events: T[], errors: any[]) => void;
6 | }
7 |
--------------------------------------------------------------------------------
/src/interfaces/config/index.ts:
--------------------------------------------------------------------------------
1 | export * from './event-bus-config.type';
2 | export * from './event-bus-prepublish-config.interface';
3 | export * from './read-event-bus-config.type';
4 | export * from './write-event-bus-config.interface';
5 | export * from './event-bus-prepublish-validate-provider.interface';
6 | export * from './event-bus-prepublish-prepare-provider.interface';
7 | export * from './event-bus-prepublish-prepare-callback.type';
8 |
--------------------------------------------------------------------------------
/src/interfaces/config/read-event-bus-config.type.ts:
--------------------------------------------------------------------------------
1 | import { ReadEventOptionsType, IReadEvent } from '../events';
2 | import { IEventBusPrepublishConfig } from './event-bus-prepublish-config.interface';
3 | import { EventStoreEvent } from '../../event-store';
4 |
5 | type EventMapperType = (
6 | data: any,
7 | options: ReadEventOptionsType,
8 | ) => IReadEvent | null;
9 |
10 | type EventConstructorType = new (
11 | ...args: any[]
12 | ) => T;
13 |
14 | export type ReadEventBusConfigType =
15 | IEventBusPrepublishConfig &
16 | (
17 | | {
18 | eventMapper: EventMapperType;
19 | allowedEvents?: never;
20 | }
21 | | {
22 | eventMapper?: never;
23 | allowedEvents: { [key: string]: EventConstructorType };
24 | }
25 | );
26 |
--------------------------------------------------------------------------------
/src/interfaces/config/write-event-bus-config.interface.ts:
--------------------------------------------------------------------------------
1 | import { IEvent } from '@nestjs/cqrs';
2 | import { Observable } from 'rxjs';
3 | import { ContextName } from 'nestjs-context';
4 | import { EventStorePublisher } from '../../event-store';
5 | import { IEventBusPrepublishConfig } from './event-bus-prepublish-config.interface';
6 | import { IWriteEvent } from '../events';
7 |
8 | export interface IWriteEventBusConfig
9 | extends IEventBusPrepublishConfig {
10 | context?: ContextName;
11 | serviceName?: string;
12 | // Handle publish error default do nothing
13 | onPublishFail?: (
14 | error: Error,
15 | events: IEvent[],
16 | eventStore: EventStorePublisher,
17 | ) => Observable;
18 | }
19 |
--------------------------------------------------------------------------------
/src/interfaces/events/acknowledgeable-event.interface.ts:
--------------------------------------------------------------------------------
1 | import { IReadEvent } from './read-event.interface';
2 | import { PersistentSubscriptionNakEventAction } from './persistent-subscription-nak-event-action.enum';
3 |
4 | export interface IAcknowledgeableEvent extends IReadEvent {
5 | ack: () => Promise;
6 | nack: (
7 | action: PersistentSubscriptionNakEventAction,
8 | reason: string,
9 | ) => Promise;
10 | }
11 |
--------------------------------------------------------------------------------
/src/interfaces/events/base-event.interface.ts:
--------------------------------------------------------------------------------
1 | import { EventMetadataDto } from '../../dto';
2 |
3 | export interface IBaseEvent {
4 | data: any;
5 | metadata?: Partial;
6 | eventId?: string;
7 | eventType?: string;
8 | }
9 |
--------------------------------------------------------------------------------
/src/interfaces/events/event-options.type.ts:
--------------------------------------------------------------------------------
1 | import { IWriteEvent } from './write-event.interface';
2 | import { ReadEventOptionsType } from './read-event-options.type';
3 |
4 | type WriteEventOptionsType = Omit & {
5 | eventStreamId?: never;
6 | eventNumber?: never;
7 | originalEventId?: never;
8 | };
9 |
10 | export type EventOptionsType = ReadEventOptionsType | WriteEventOptionsType;
11 |
--------------------------------------------------------------------------------
/src/interfaces/events/index.ts:
--------------------------------------------------------------------------------
1 | export * from './acknowledgeable-event.interface';
2 | export * from './base-event.interface';
3 | export * from './event-options.type';
4 | export * from './publication-context.interface';
5 | export * from './read-event.interface';
6 | export * from './read-event-options.type';
7 | export * from './write-event.interface';
8 |
--------------------------------------------------------------------------------
/src/interfaces/events/persistent-subscription-nak-event-action.enum.ts:
--------------------------------------------------------------------------------
1 | export enum PersistentSubscriptionNakEventAction {
2 | Unknown = 0,
3 | Park = 1,
4 | Retry = 2,
5 | Skip = 3,
6 | Stop = 4,
7 | }
8 |
--------------------------------------------------------------------------------
/src/interfaces/events/publication-context.interface.ts:
--------------------------------------------------------------------------------
1 | import { StreamMetadata } from '@eventstore/db-client/dist/utils/streamMetadata';
2 | import { SetStreamMetadataOptions } from '@eventstore/db-client/dist/streams';
3 | import { AppendExpectedRevision } from '@eventstore/db-client/dist/types';
4 |
5 | export interface PublicationContextInterface {
6 | streamName?: string;
7 | expectedRevision?: AppendExpectedRevision;
8 | streamMetadata?: StreamMetadata;
9 | options?: SetStreamMetadataOptions;
10 | }
11 |
--------------------------------------------------------------------------------
/src/interfaces/events/read-event-options.type.ts:
--------------------------------------------------------------------------------
1 | import { IReadEvent } from './read-event.interface';
2 |
3 | export type ReadEventOptionsType = Omit<
4 | IReadEvent,
5 | 'data' | 'getStream' | 'getStreamCategory' | 'getStreamId'
6 | >;
7 |
--------------------------------------------------------------------------------
/src/interfaces/events/read-event.interface.ts:
--------------------------------------------------------------------------------
1 | import { IBaseEvent } from './base-event.interface';
2 |
3 | export interface IReadEvent extends IBaseEvent {
4 | eventStreamId: string;
5 | eventNumber: number;
6 | originalEventId: string;
7 | getStream(): string;
8 | getStreamCategory(): string;
9 | getStreamId(): string;
10 | }
11 |
--------------------------------------------------------------------------------
/src/interfaces/events/write-event.interface.ts:
--------------------------------------------------------------------------------
1 | import { IBaseEvent } from './base-event.interface';
2 |
3 | export interface IWriteEvent extends IBaseEvent {}
4 |
--------------------------------------------------------------------------------
/src/interfaces/index.ts:
--------------------------------------------------------------------------------
1 | export * from './config';
2 | export * from './events';
3 |
4 | export * from './projection.type';
5 | export * from './write-event-bus.interface';
6 |
--------------------------------------------------------------------------------
/src/interfaces/projection.type.ts:
--------------------------------------------------------------------------------
1 | export type EventStoreProjection = {
2 | name: string;
3 | content?: string;
4 | file?: string;
5 |
6 | mode?: 'oneTime' | 'continuous' | 'transient';
7 | trackEmittedStreams?: boolean;
8 | enabled?: boolean;
9 | checkPointsEnabled?: boolean;
10 | emitEnabled?: boolean;
11 | };
12 |
--------------------------------------------------------------------------------
/src/interfaces/write-event-bus.interface.ts:
--------------------------------------------------------------------------------
1 | import { WriteEventBus } from '../cqrs';
2 |
3 | export interface IWriteEventBus extends WriteEventBus {}
4 |
--------------------------------------------------------------------------------
/src/tools/create-event-default-metadata.ts:
--------------------------------------------------------------------------------
1 | import { EventMetadataDto } from '../dto';
2 |
3 | export const createEventDefaultMetadata = () =>
4 | ({
5 | time: new Date().toISOString(),
6 | version: 1,
7 | } as Partial);
8 |
--------------------------------------------------------------------------------
/src/tools/index.ts:
--------------------------------------------------------------------------------
1 | export * from './create-event-default-metadata';
2 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "declaration": true,
5 | "removeComments": true,
6 | "emitDecoratorMetadata": true,
7 | "experimentalDecorators": true,
8 | "target": "ES2020",
9 | "sourceMap": false,
10 | "outDir": "./dist",
11 | "rootDir": "./src",
12 | "baseUrl": "./",
13 | "noLib": false
14 | },
15 | "include": ["src/**/*.ts"],
16 | "exclude": ["node_modules", "./dist"]
17 | }
18 |
--------------------------------------------------------------------------------
/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "module": "commonjs",
5 | "declaration": true,
6 | "removeComments": true,
7 | "emitDecoratorMetadata": true,
8 | "experimentalDecorators": true,
9 | "target": "ES2020",
10 | "sourceMap": true,
11 | "rootDir": "./src",
12 | "baseUrl": "./",
13 | "noLib": false
14 | },
15 | "include": ["src/**/*.spec.ts"],
16 | "exclude": ["node_modules"]
17 | }
18 |
--------------------------------------------------------------------------------
/upgrade.md:
--------------------------------------------------------------------------------
1 | # Updating connector, what's new
2 |
3 | ## Version 5.0.0
4 |
5 | The connector is updated, and the deprecated version of EventStore client is not maintained anymore.
6 |
7 | 1. EventStore Client
8 |
9 | The usage is switching to the official
10 | client [EventStore client](https://developers.eventstore.com/clients/grpc/getting-started/)
11 |
12 | 2. App startup
13 |
14 | Connecting to the module is now done only by providing a conf that you can find here :
15 |
16 | [src/event-store/config/event-store-connection-config.ts](./src/event-store/config/event-store-connection-config.ts)
17 |
18 | The conf and all the objects are now (or willing to be) strongly typed, so it's easy to know what option is needed.
19 |
20 | 3. EventStore configuration
21 |
22 | You can give a strongly typed conf representing the persistent subscriptions and the projections, at the startup :
23 |
24 | [src/event-store/config/event-store-service-config.interface.ts](./src/event-store/config/event-store-service-config.interface.ts)
25 |
26 | The main diff is that we have now to fill it with a creation conf and a connection conf. They are not the same anymore (
27 | in order to match the es client's process).
28 |
29 | 4. Methods signature
30 |
31 | Some EventStoreService methods have see there signature changed to suit at best the interfaces of the new client :
32 |
33 | [src/event-store/services/event-store.service.interface.ts](./src/event-store/services/event-store.service.interface.ts)
34 |
35 | 6. Exemple : how to connect
36 |
37 | First step : prepare your eventstore connection configuration, like this :
38 |
39 | ```typescript
40 | const eventStoreConnectionConfig: EventStoreConnectionConfig = {
41 | connectionSettings: {
42 | connectionString:
43 | process.env.CONNECTION_STRING || 'esdb://localhost:20113?tls=false',
44 | },
45 | defaultUserCredentials: {
46 | username: process.env.EVENTSTORE_CREDENTIALS_USERNAME || 'admin',
47 | password: process.env.EVENTSTORE_CREDENTIALS_PASSWORD || 'changeit',
48 | },
49 | };
50 | ```
51 |
52 | Then, you must provide the list of subsystems you want to configure (projections/persistent subscriptions) :
53 |
54 | ```typescript
55 | const eventStoreSubsystems: IEventStoreSubsystems = {
56 | subscriptions: {
57 | persistent: [
58 | {
59 | stream: '$ce-hero',
60 | group: 'data',
61 | settingsForCreation: {
62 | subscriptionSettings: {
63 | resolveLinkTos: true,
64 | minCheckpointCount: 1,
65 | },
66 | },
67 | onError: (err: Error) =>
68 | console.log(`An error occured : ${err.name}, ${err.message}`),
69 | },
70 | ],
71 | },
72 | projections: [
73 | {
74 | name: 'hero-dragon',
75 | file: resolve(`${__dirname}/projections/hero-dragon.js`),
76 | mode: 'continuous',
77 | enabled: true,
78 | checkPointsEnabled: true,
79 | emitEnabled: true,
80 | },
81 | ],
82 | onConnectionFail: (err: Error) =>
83 | console.log(`Connection to Event store hooked : ${err}`),
84 | };
85 | ```
86 |
87 | **Note on error callbacks**
88 |
89 | - the onError callback will be triggered when the subscription will face a unexpected issue (for example : EventStore
90 | connection is closed). This allows you to stack your events/do anything else.
91 | - Same, you have now a `onConnectionFail` that you can give to the conf. This will be triggered if the connection to EventStore is failing, while you try to write event(s).
92 |
93 | Again, all of these configurations are strongly typed, you can check the interfaces to know what options are needed or
94 | not.
95 |
96 | Note: for creation options, even if you do not provide all options needed, the system will fill it with default values,
97 | given by calling the `persistentSubscriptionSettingsFromDefaults` method provided by the client lib.
98 |
99 | Then, one last step, you have to provide the readBus and writeBus options, like previously.
100 |
101 | In your module, you then have to import the `CqrsEventStoreModule` like this way :
102 |
103 | ```typescript
104 | @Module({
105 | controllers: [
106 | // ... OtherControllers
107 | ],
108 | providers: [
109 | // ... OtherProviders
110 | ],
111 | imports: [
112 | OtherModules,
113 | CqrsEventStoreModule.register(
114 | eventStoreConnectionConfig,
115 | eventStoreSubsystems,
116 | eventBusConfig,
117 | ),
118 | ],
119 | })
120 | export class YourCoolFeatureModule {}
121 | ```
122 |
123 | Because the connection is at module init, once the app is started, all the projections and persistent subscriptions
124 | provided are asserted. You can get the subscriptions by this way :
125 |
126 | ```typescript
127 | EventStoreService.getPersistentSubscriptions();
128 | ```
129 |
130 | ### Update with command handlers and the aggregate
131 |
132 | The main difference with the command handlers concerns the commit parameter. Now, the signature is this one :
133 |
134 | ```typescript
135 | interface ExampleAggregate {
136 | commit(
137 | expectedRevision: AppendExpectedRevision = constants.ANY,
138 | expectedMetadataRevision: AppendExpectedRevision = constants.ANY,
139 | );
140 | }
141 | ```
142 |
143 | The constants are findable here :
144 |
145 | [@eventstore/db-client/dist/constants.d.ts](./node_modules/@eventstore/db-client/dist/constants.d.ts)
146 |
147 | The possibilities are :
148 |
149 | `constants.NO_STREAM | constants.STREAM_EXISTS | constants.ANY | bigint;`
150 |
151 | Note that if you want to declare a bigint at this place, you have to do like this :
152 |
153 | ```typescript
154 | const veryBigInt: bigint = BigInt(1234);
155 | ```
156 |
157 | In the command handler, when you want to commit, you should then provide the right value for expected versions.
158 |
159 | ### Failing strategy
160 |
161 | By default, the failing strategy keeps in memory the event batches, while the connection to the store is down. Each time you try to write events, the system will try to rewrite all the events stacked in the right order with the correct stream and expected revision.
162 |
163 | You may want to override this mechanism. To do so, the only thing you have to provide is `EVENT_STACKER` service, like this in your module :
164 |
165 | ```typescript
166 | {
167 | provide: EVENT_AND_METADATAS_STACKER,
168 | useClass: InMemoryEventsAndMetadatasStacker
169 | }
170 | ```
171 |
172 | `InMemoryEventsAndMetadatasStacker` is the devault value. you juste have to add a service that matches the interface [IEventsAndMetadatasStacker](src/event-store/reliability/interface/events-and-metadatas-stacker.ts)
173 |
174 | Note that it works the same way for the metadatas.
175 |
--------------------------------------------------------------------------------