cds-error-outbox 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,46 +1,46 @@
1
- 'use strict';
2
-
3
- module.exports = {
4
- enabled: true,
5
-
6
- /** How often (ms) the batch job runs to send unsent errors. */
7
- interval: 300000,
8
-
9
- /** Maximum number of unsent errors to include per email batch. */
10
- batchSize: 50,
11
-
12
- dedup: {
13
- /** Whether to deduplicate errors by hash within the rolling window. */
14
- enabled: true,
15
-
16
- /** Only deduplicate within this time window (minutes). */
17
- windowMinutes: 10
18
- },
19
-
20
- mail: {
21
- /** Email provider: 'o365' | 'smtp' | 'mock' */
22
- provider: 'mock',
23
-
24
- /** Sender address (required for o365 and smtp). */
25
- from: '',
26
-
27
- /** Recipient address(es) — comma-separated for multiple (required for o365 and smtp). */
28
- to: '',
29
-
30
- /** === O365 / Microsoft Graph API options === */
31
- tenantId: '',
32
- clientId: '',
33
- clientSecret: '',
34
-
35
- /** === SMTP options (used only when provider = 'smtp') === */
36
- smtp: {
37
- host: '',
38
- port: 587,
39
- secure: false,
40
- auth: {
41
- user: '',
42
- pass: ''
43
- }
44
- }
45
- }
46
- };
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ enabled: true,
5
+
6
+ /** How often (ms) the batch job runs to send unsent errors. */
7
+ interval: 300000,
8
+
9
+ /** Maximum number of unsent errors to include per email batch. */
10
+ batchSize: 50,
11
+
12
+ dedup: {
13
+ /** Whether to deduplicate errors by hash within the rolling window. */
14
+ enabled: true,
15
+
16
+ /** Only deduplicate within this time window (minutes). */
17
+ windowMinutes: 10
18
+ },
19
+
20
+ mail: {
21
+ /** Email provider: 'o365' | 'smtp' | 'mock' */
22
+ provider: 'mock',
23
+
24
+ /** Sender address (required for o365 and smtp). */
25
+ from: '',
26
+
27
+ /** Recipient address(es) — comma-separated for multiple (required for o365 and smtp). */
28
+ to: '',
29
+
30
+ /** === O365 / Microsoft Graph API options === */
31
+ tenantId: '',
32
+ clientId: '',
33
+ clientSecret: '',
34
+
35
+ /** === SMTP options (used only when provider = 'smtp') === */
36
+ smtp: {
37
+ host: '',
38
+ port: 587,
39
+ secure: false,
40
+ auth: {
41
+ user: '',
42
+ pass: ''
43
+ }
44
+ }
45
+ }
46
+ };
package/db/model.cds CHANGED
@@ -1,18 +1,18 @@
1
- namespace error.outbox;
2
-
3
- entity Errors {
4
- key ID : UUID;
5
-
6
- hash : String(64);
7
- service : String;
8
- action : String;
9
-
10
- message : LargeString;
11
- stack : LargeString;
12
-
13
- count : Integer;
14
- firstSeen : Timestamp;
15
- lastSeen : Timestamp;
16
-
17
- sent : Boolean default false;
18
- }
1
+ namespace error.outbox;
2
+
3
+ entity Errors {
4
+ key ID : UUID;
5
+
6
+ hash : String(64);
7
+ service : String;
8
+ action : String;
9
+
10
+ message : LargeString;
11
+ stack : LargeString;
12
+
13
+ count : Integer;
14
+ firstSeen : Timestamp;
15
+ lastSeen : Timestamp;
16
+
17
+ sent : Boolean default false;
18
+ }
package/index.cds CHANGED
@@ -1 +1 @@
1
- using from './db/model';
1
+ using from './db/model';
package/index.js CHANGED
@@ -1,14 +1,14 @@
1
- 'use strict';
2
-
3
- /**
4
- * cds-error-outbox — CAP plugin entry point.
5
- *
6
- * CAP automatically requires this file when the package is installed,
7
- * because package.json declares `"cds": { "plugin": true }`.
8
- *
9
- * The bootstrap.initialize() call registers CAP lifecycle listeners
10
- * (cds.on('serving') and cds.on('served')) so no blocking work happens here.
11
- */
12
- const { initialize } = require('./lib/bootstrap');
13
-
14
- initialize();
1
+ 'use strict';
2
+
3
+ /**
4
+ * cds-error-outbox — CAP plugin entry point.
5
+ *
6
+ * CAP automatically requires this file when the package is installed,
7
+ * because package.json declares `"cds": { "plugin": true }`.
8
+ *
9
+ * The bootstrap.initialize() call registers CAP lifecycle listeners
10
+ * (cds.on('serving') and cds.on('served')) so no blocking work happens here.
11
+ */
12
+ const { initialize } = require('./lib/bootstrap');
13
+
14
+ initialize();
package/lib/bootstrap.js CHANGED
@@ -1,71 +1,71 @@
1
- 'use strict';
2
-
3
- const cds = require('@sap/cds');
4
- const { loadConfig } = require('./config');
5
- const { attach } = require('./interceptor');
6
- const { start } = require('./scheduler');
7
- const { getProvider } = require('../providers');
8
-
9
- /**
10
- * Initialize the cds-error-outbox plugin.
11
- *
12
- * Called once at plugin load time (from index.js).
13
- * Uses CAP lifecycle events to ensure initialization order:
14
- *
15
- * cds.on('serving') → attach error interceptor to each served service
16
- * cds.on('served') → start scheduler (DB is fully connected at this point)
17
- */
18
- function initialize() {
19
- // ── Load and validate config ─────────────────────────────────────────────
20
- let config;
21
- try {
22
- config = loadConfig();
23
- } catch (err) {
24
- console.error('[cds-error-outbox] Failed to load config — plugin disabled:', err.message);
25
- return;
26
- }
27
-
28
- if (!config.enabled) {
29
- console.info('[cds-error-outbox] Plugin is disabled (config.enabled = false).');
30
- return;
31
- }
32
-
33
- // ── Resolve email provider ───────────────────────────────────────────────
34
- let provider;
35
- try {
36
- provider = getProvider(config);
37
- } catch (err) {
38
- console.error('[cds-error-outbox] Failed to load email provider — plugin disabled:', err.message);
39
- return;
40
- }
41
-
42
- // ── Attach interceptor to every served service ───────────────────────────
43
- cds.on('serving', (srv) => {
44
- try {
45
- attach(srv, config);
46
- } catch (err) {
47
- console.error(
48
- `[cds-error-outbox] Failed to attach interceptor to service "${srv.name}":`,
49
- err.message
50
- );
51
- }
52
- });
53
-
54
- // ── Start scheduler after all services + DB are ready ───────────────────
55
- // cds.on('served') fires once after ALL services have been bootstrapped
56
- // and cds.db is guaranteed to be available.
57
- cds.on('served', () => {
58
- try {
59
- start(config, provider);
60
- } catch (err) {
61
- console.error('[cds-error-outbox] Failed to start scheduler:', err.message);
62
- }
63
- });
64
-
65
- console.info(
66
- `[cds-error-outbox] Plugin initialized — ` +
67
- `provider: ${config.mail.provider}, interval: ${config.interval}ms`
68
- );
69
- }
70
-
71
- module.exports = { initialize };
1
+ 'use strict';
2
+
3
+ const cds = require('@sap/cds');
4
+ const { loadConfig } = require('./config');
5
+ const { attach } = require('./interceptor');
6
+ const { start } = require('./scheduler');
7
+ const { getProvider } = require('../providers');
8
+
9
+ /**
10
+ * Initialize the cds-error-outbox plugin.
11
+ *
12
+ * Called once at plugin load time (from index.js).
13
+ * Uses CAP lifecycle events to ensure initialization order:
14
+ *
15
+ * cds.on('serving') → attach error interceptor to each served service
16
+ * cds.on('served') → start scheduler (DB is fully connected at this point)
17
+ */
18
+ function initialize() {
19
+ // ── Load and validate config ─────────────────────────────────────────────
20
+ let config;
21
+ try {
22
+ config = loadConfig();
23
+ } catch (err) {
24
+ console.error('[cds-error-outbox] Failed to load config — plugin disabled:', err.message);
25
+ return;
26
+ }
27
+
28
+ if (!config.enabled) {
29
+ console.info('[cds-error-outbox] Plugin is disabled (config.enabled = false).');
30
+ return;
31
+ }
32
+
33
+ // ── Resolve email provider ───────────────────────────────────────────────
34
+ let provider;
35
+ try {
36
+ provider = getProvider(config);
37
+ } catch (err) {
38
+ console.error('[cds-error-outbox] Failed to load email provider — plugin disabled:', err.message);
39
+ return;
40
+ }
41
+
42
+ // ── Attach interceptor to every served service ───────────────────────────
43
+ cds.on('serving', (srv) => {
44
+ try {
45
+ attach(srv, config);
46
+ } catch (err) {
47
+ console.error(
48
+ `[cds-error-outbox] Failed to attach interceptor to service "${srv.name}":`,
49
+ err.message
50
+ );
51
+ }
52
+ });
53
+
54
+ // ── Start scheduler after all services + DB are ready ───────────────────
55
+ // cds.on('served') fires once after ALL services have been bootstrapped
56
+ // and cds.db is guaranteed to be available.
57
+ cds.on('served', () => {
58
+ try {
59
+ start(config, provider);
60
+ } catch (err) {
61
+ console.error('[cds-error-outbox] Failed to start scheduler:', err.message);
62
+ }
63
+ });
64
+
65
+ console.info(
66
+ `[cds-error-outbox] Plugin initialized — ` +
67
+ `provider: ${config.mail.provider}, interval: ${config.interval}ms`
68
+ );
69
+ }
70
+
71
+ module.exports = { initialize };
package/lib/config.js CHANGED
@@ -1,67 +1,67 @@
1
- 'use strict';
2
-
3
- const cds = require('@sap/cds');
4
- const defaults = require('../config/defaults');
5
-
6
- let _config = null;
7
-
8
- /**
9
- * Recursively deep-merge source into target.
10
- * Arrays in source replace arrays in target (no merge).
11
- *
12
- * @param {Object} target
13
- * @param {Object} source
14
- * @returns {Object}
15
- */
16
- function deepMerge(target, source) {
17
- if (!source || typeof source !== 'object') return target;
18
-
19
- const result = Object.assign({}, target);
20
-
21
- for (const key of Object.keys(source)) {
22
- const srcVal = source[key];
23
- const tgtVal = result[key];
24
-
25
- if (
26
- srcVal !== null &&
27
- typeof srcVal === 'object' &&
28
- !Array.isArray(srcVal) &&
29
- tgtVal !== null &&
30
- typeof tgtVal === 'object' &&
31
- !Array.isArray(tgtVal)
32
- ) {
33
- result[key] = deepMerge(tgtVal, srcVal);
34
- } else if (srcVal !== undefined) {
35
- result[key] = srcVal;
36
- }
37
- }
38
-
39
- return result;
40
- }
41
-
42
- /**
43
- * Load and cache the merged config (defaults + cds.env.requires.errorOutbox).
44
- * @returns {Object}
45
- */
46
- function loadConfig() {
47
- if (_config) return _config;
48
-
49
- const userConfig =
50
- (cds.env &&
51
- cds.env.requires &&
52
- cds.env.requires.errorOutbox) ||
53
- {};
54
-
55
- _config = deepMerge(defaults, userConfig);
56
- return _config;
57
- }
58
-
59
- /**
60
- * Reset the cached config singleton.
61
- * Useful in tests to reload config between test cases.
62
- */
63
- function resetConfig() {
64
- _config = null;
65
- }
66
-
67
- module.exports = { loadConfig, resetConfig, deepMerge };
1
+ 'use strict';
2
+
3
+ const cds = require('@sap/cds');
4
+ const defaults = require('../config/defaults');
5
+
6
+ let _config = null;
7
+
8
+ /**
9
+ * Recursively deep-merge source into target.
10
+ * Arrays in source replace arrays in target (no merge).
11
+ *
12
+ * @param {Object} target
13
+ * @param {Object} source
14
+ * @returns {Object}
15
+ */
16
+ function deepMerge(target, source) {
17
+ if (!source || typeof source !== 'object') return target;
18
+
19
+ const result = Object.assign({}, target);
20
+
21
+ for (const key of Object.keys(source)) {
22
+ const srcVal = source[key];
23
+ const tgtVal = result[key];
24
+
25
+ if (
26
+ srcVal !== null &&
27
+ typeof srcVal === 'object' &&
28
+ !Array.isArray(srcVal) &&
29
+ tgtVal !== null &&
30
+ typeof tgtVal === 'object' &&
31
+ !Array.isArray(tgtVal)
32
+ ) {
33
+ result[key] = deepMerge(tgtVal, srcVal);
34
+ } else if (srcVal !== undefined) {
35
+ result[key] = srcVal;
36
+ }
37
+ }
38
+
39
+ return result;
40
+ }
41
+
42
+ /**
43
+ * Load and cache the merged config (defaults + cds.env.requires.errorOutbox).
44
+ * @returns {Object}
45
+ */
46
+ function loadConfig() {
47
+ if (_config) return _config;
48
+
49
+ const userConfig =
50
+ (cds.env &&
51
+ cds.env.requires &&
52
+ cds.env.requires.errorOutbox) ||
53
+ {};
54
+
55
+ _config = deepMerge(defaults, userConfig);
56
+ return _config;
57
+ }
58
+
59
+ /**
60
+ * Reset the cached config singleton.
61
+ * Useful in tests to reload config between test cases.
62
+ */
63
+ function resetConfig() {
64
+ _config = null;
65
+ }
66
+
67
+ module.exports = { loadConfig, resetConfig, deepMerge };
package/lib/dedup.js CHANGED
@@ -1,91 +1,91 @@
1
- 'use strict';
2
-
3
- const crypto = require('crypto');
4
-
5
- const ENTITY = 'error.outbox.Errors';
6
-
7
- // Truncation limits to prevent oversized DB entries
8
- const MAX_MESSAGE_LEN = 5000;
9
- const MAX_STACK_LEN = 10000;
10
-
11
- /**
12
- * Creates a deterministic SHA-256 hash from the error's message, service, and action.
13
- * Used as the dedup key.
14
- *
15
- * @param {string} message
16
- * @param {string} service
17
- * @param {string} action
18
- * @returns {string} 64-character hex string
19
- */
20
- function createHash(message, service, action) {
21
- return crypto
22
- .createHash('sha256')
23
- .update(`${message}|${service}|${action}`)
24
- .digest('hex');
25
- }
26
-
27
- /**
28
- * Find an existing unsent error record matching the given hash within the dedup window.
29
- *
30
- * @param {object} db - connected cds.db instance
31
- * @param {string} hash
32
- * @param {number} windowMinutes
33
- * @returns {Promise<object|null>}
34
- */
35
- async function findExistingError(db, hash, windowMinutes) {
36
- const cutoff = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();
37
-
38
- const results = await db.run(
39
- SELECT.from(ENTITY)
40
- .where({ hash })
41
- .and('lastSeen >', cutoff)
42
- .and({ sent: false })
43
- .limit(1)
44
- );
45
-
46
- return results && results.length > 0 ? results[0] : null;
47
- }
48
-
49
- /**
50
- * Insert a new error row or increment the count on an existing one.
51
- * Deduplication is based on the hash + dedup window + sent=false.
52
- *
53
- * @param {object} db - connected cds.db instance
54
- * @param {object} errorData - { message, stack, service, action }
55
- * @param {object} config - merged plugin config
56
- */
57
- async function upsertError(db, errorData, config) {
58
- const { message, stack, service, action } = errorData;
59
-
60
- const hash = createHash(message, service, action);
61
- const now = new Date().toISOString();
62
-
63
- const existing = config.dedup.enabled
64
- ? await findExistingError(db, hash, config.dedup.windowMinutes)
65
- : null;
66
-
67
- if (existing) {
68
- await db.run(
69
- UPDATE(ENTITY)
70
- .set({ count: existing.count + 1, lastSeen: now })
71
- .where({ ID: existing.ID })
72
- );
73
- } else {
74
- await db.run(
75
- INSERT.into(ENTITY).entries({
76
- ID: crypto.randomUUID(),
77
- hash,
78
- service: String(service || 'unknown'),
79
- action: String(action || 'unknown'),
80
- message: message ? String(message).substring(0, MAX_MESSAGE_LEN) : '',
81
- stack: stack ? String(stack).substring(0, MAX_STACK_LEN) : '',
82
- count: 1,
83
- firstSeen: now,
84
- lastSeen: now,
85
- sent: false
86
- })
87
- );
88
- }
89
- }
90
-
91
- module.exports = { createHash, findExistingError, upsertError };
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ const ENTITY = 'error.outbox.Errors';
6
+
7
+ // Truncation limits to prevent oversized DB entries
8
+ const MAX_MESSAGE_LEN = 5000;
9
+ const MAX_STACK_LEN = 10000;
10
+
11
+ /**
12
+ * Creates a deterministic SHA-256 hash from the error's message, service, and action.
13
+ * Used as the dedup key.
14
+ *
15
+ * @param {string} message
16
+ * @param {string} service
17
+ * @param {string} action
18
+ * @returns {string} 64-character hex string
19
+ */
20
+ function createHash(message, service, action) {
21
+ return crypto
22
+ .createHash('sha256')
23
+ .update(`${message}|${service}|${action}`)
24
+ .digest('hex');
25
+ }
26
+
27
+ /**
28
+ * Find an existing unsent error record matching the given hash within the dedup window.
29
+ *
30
+ * @param {object} db - connected cds.db instance
31
+ * @param {string} hash
32
+ * @param {number} windowMinutes
33
+ * @returns {Promise<object|null>}
34
+ */
35
+ async function findExistingError(db, hash, windowMinutes) {
36
+ const cutoff = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();
37
+
38
+ const results = await db.run(
39
+ SELECT.from(ENTITY)
40
+ .where({ hash })
41
+ .and('lastSeen >', cutoff)
42
+ .and({ sent: false })
43
+ .limit(1)
44
+ );
45
+
46
+ return results && results.length > 0 ? results[0] : null;
47
+ }
48
+
49
+ /**
50
+ * Insert a new error row or increment the count on an existing one.
51
+ * Deduplication is based on the hash + dedup window + sent=false.
52
+ *
53
+ * @param {object} db - connected cds.db instance
54
+ * @param {object} errorData - { message, stack, service, action }
55
+ * @param {object} config - merged plugin config
56
+ */
57
+ async function upsertError(db, errorData, config) {
58
+ const { message, stack, service, action } = errorData;
59
+
60
+ const hash = createHash(message, service, action);
61
+ const now = new Date().toISOString();
62
+
63
+ const existing = config.dedup.enabled
64
+ ? await findExistingError(db, hash, config.dedup.windowMinutes)
65
+ : null;
66
+
67
+ if (existing) {
68
+ await db.run(
69
+ UPDATE(ENTITY)
70
+ .set({ count: existing.count + 1, lastSeen: now })
71
+ .where({ ID: existing.ID })
72
+ );
73
+ } else {
74
+ await db.run(
75
+ INSERT.into(ENTITY).entries({
76
+ ID: crypto.randomUUID(),
77
+ hash,
78
+ service: String(service || 'unknown'),
79
+ action: String(action || 'unknown'),
80
+ message: message ? String(message).substring(0, MAX_MESSAGE_LEN) : '',
81
+ stack: stack ? String(stack).substring(0, MAX_STACK_LEN) : '',
82
+ count: 1,
83
+ firstSeen: now,
84
+ lastSeen: now,
85
+ sent: false
86
+ })
87
+ );
88
+ }
89
+ }
90
+
91
+ module.exports = { createHash, findExistingError, upsertError };