anote-server-libs 0.11.9 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anote-server-libs",
3
- "version": "0.11.9",
3
+ "version": "0.12.1",
4
4
  "description": "Helpers for express-TS servers",
5
5
  "scripts": {
6
6
  "test": "echo \"Error: no test specified\" && exit 1",
@@ -1,14 +1,63 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.withTransactionConfig = void 0;
4
+ exports.requestTransactionGate = requestTransactionGate;
5
+ exports.releaseTransactionGate = releaseTransactionGate;
4
6
  exports.withTransaction = withTransaction;
5
7
  const utils_1 = require("./utils");
6
8
  exports.withTransactionConfig = {
7
9
  logQueries: false,
8
- timeoutMillis: 15000
10
+ timeoutMillis: 15000,
11
+ activeCount: 0,
12
+ gateRequested: false,
13
+ gateActive: false,
14
+ gateQueue: [],
9
15
  };
10
- function withTransaction(repo, logger, previousMethod, lock, commitIfLost = true) {
16
+ function requestTransactionGate() {
17
+ if (exports.withTransactionConfig.gateActive || exports.withTransactionConfig.gateRequested) {
18
+ return Promise.reject(new Error('Transaction gate already active or requested'));
19
+ }
20
+ exports.withTransactionConfig.gateRequested = true;
21
+ return new Promise((resolve, _reject) => {
22
+ const check = () => {
23
+ if (exports.withTransactionConfig.activeCount <= 1) {
24
+ exports.withTransactionConfig.gateActive = true;
25
+ exports.withTransactionConfig.gateRequested = false;
26
+ resolve();
27
+ }
28
+ else {
29
+ setTimeout(check, 100);
30
+ }
31
+ };
32
+ setTimeout(check, 50);
33
+ });
34
+ }
35
+ function releaseTransactionGate() {
36
+ exports.withTransactionConfig.gateActive = false;
37
+ exports.withTransactionConfig.gateRequested = false;
38
+ const queue = exports.withTransactionConfig.gateQueue.splice(0);
39
+ for (const entry of queue) {
40
+ entry.resolve();
41
+ }
42
+ }
43
+ function releaseAndDecrement(repo, dbClient) {
44
+ if (repo.db && dbClient) {
45
+ try {
46
+ dbClient.release();
47
+ }
48
+ catch (_) { }
49
+ }
50
+ exports.withTransactionConfig.activeCount = Math.max(0, exports.withTransactionConfig.activeCount - 1);
51
+ }
52
+ function withTransaction(repo, logger, previousMethod, lock, commitIfLost = true, idleInTransactionTimeout) {
11
53
  return function (req, res, next) {
54
+ if (exports.withTransactionConfig.gateActive) {
55
+ return new Promise((resolve, reject) => {
56
+ exports.withTransactionConfig.gateQueue.push({ resolve, reject });
57
+ }).then(() => {
58
+ return withTransaction(repo, logger, previousMethod, lock, commitIfLost)(req, res, next);
59
+ });
60
+ }
12
61
  let commit = true;
13
62
  if (!commitIfLost) {
14
63
  res.once('close', () => commit = false);
@@ -22,6 +71,7 @@ function withTransaction(repo, logger, previousMethod, lock, commitIfLost = true
22
71
  res.write((0, utils_1.jsonStringify)(obj) || '{}');
23
72
  endTerminator();
24
73
  };
74
+ exports.withTransactionConfig.activeCount++;
25
75
  const connectTimeoutHandler = setTimeout(() => {
26
76
  logger.error('Error timed out getting a client, exiting...');
27
77
  process.exit(22);
@@ -49,8 +99,7 @@ function withTransaction(repo, logger, previousMethod, lock, commitIfLost = true
49
99
  if (!res.locals.dbClientCommited) {
50
100
  res.locals.dbClientCommited = true;
51
101
  (commit ? (repo.db ? dbClient.query('COMMIT') : dbClient.commit()) : Promise.reject(new Error('Client lost'))).catch((err) => err).then((err) => {
52
- if (repo.db)
53
- dbClient.release();
102
+ releaseAndDecrement(repo, dbClient);
54
103
  if (!(err instanceof Error)) {
55
104
  for (let i = 0; i < res.locals.dbClientOnCommit.length; i++) {
56
105
  res.locals.dbClientOnCommit[i]();
@@ -64,7 +113,15 @@ function withTransaction(repo, logger, previousMethod, lock, commitIfLost = true
64
113
  }
65
114
  };
66
115
  res.locals.dbClientOnCommit = [];
67
- return (repo.db ? dbClient.query('BEGIN') : dbClient.begin()).then(() => {
116
+ const beginPromise = repo.db ? dbClient.query('BEGIN') : dbClient.begin();
117
+ const idleTimeoutMs = Math.max(idleInTransactionTimeout, exports.withTransactionConfig.timeoutMillis);
118
+ let setupPromise = beginPromise;
119
+ if (idleInTransactionTimeout && idleTimeoutMs > 0 && repo.db) {
120
+ setupPromise = setupPromise.then(() => {
121
+ return dbClient.query(`SET LOCAL idle_in_transaction_session_timeout TO ${idleTimeoutMs}`);
122
+ });
123
+ }
124
+ return setupPromise.then(() => {
68
125
  const finish = () => {
69
126
  res.send = () => {
70
127
  throw new Error('res.send() should not be used within transactions. Use res.json() instead.');
@@ -80,8 +137,7 @@ function withTransaction(repo, logger, previousMethod, lock, commitIfLost = true
80
137
  }
81
138
  }
82
139
  (repo.db ? dbClient.query('ROLLBACK') : dbClient.rollback()).catch((err) => obj && obj.error && (obj.error.additionalInfo2 = { message: err.message })).then(() => {
83
- if (repo.db)
84
- dbClient.release();
140
+ releaseAndDecrement(repo, dbClient);
85
141
  jsonTerminator(obj);
86
142
  });
87
143
  }
@@ -108,8 +164,7 @@ function withTransaction(repo, logger, previousMethod, lock, commitIfLost = true
108
164
  logger.error('Uncaught 500 with no details...');
109
165
  }
110
166
  (repo.db ? dbClient.query('ROLLBACK') : dbClient.rollback()).catch(() => undefined).then(() => {
111
- if (repo.db)
112
- dbClient.release();
167
+ releaseAndDecrement(repo, dbClient);
113
168
  endTerminator();
114
169
  });
115
170
  }
@@ -140,18 +195,25 @@ function withTransaction(repo, logger, previousMethod, lock, commitIfLost = true
140
195
  additionalInfo: { message: err.message }
141
196
  }
142
197
  });
143
- dbClient.release();
198
+ releaseAndDecrement(repo, dbClient);
144
199
  });
145
200
  }
146
201
  else {
147
202
  finish();
148
203
  }
149
204
  }).catch((err) => {
150
- dbClient.release();
151
- throw err;
205
+ releaseAndDecrement(repo, dbClient);
206
+ logger.error('Error beginning transaction: %j', err);
207
+ res.status(500).json({
208
+ error: {
209
+ errorKey: 'internal.db',
210
+ additionalInfo: { message: err.message }
211
+ }
212
+ });
152
213
  });
153
214
  }).catch(err => {
154
215
  clearTimeout(connectTimeoutHandler);
216
+ exports.withTransactionConfig.activeCount = Math.max(0, exports.withTransactionConfig.activeCount - 1);
155
217
  res.status(500).json({
156
218
  error: {
157
219
  errorKey: 'internal.db',
@@ -11,11 +11,88 @@ export const enum SystemLock {
11
11
 
12
12
  export const withTransactionConfig = {
13
13
  logQueries: false,
14
- timeoutMillis: 15000
14
+ timeoutMillis: 15000,
15
+ // Number of currently active transactions tracked for gating.
16
+ activeCount: 0,
17
+ // Whether a gate has been requested (waiting for activeCount to reach 0).
18
+ gateRequested: false,
19
+ // Whether the gate is active (new transactions are queued).
20
+ gateActive: false,
21
+ // Queue of pending transactions waiting for the gate to release.
22
+ gateQueue: [] as Array<{resolve: () => void; reject: (err: Error) => void}>,
15
23
  };
16
24
 
17
- export function withTransaction(repo: BaseModelRepository, logger: Logger, previousMethod: (req: Request, res: Response, next: NextFunction) => void, lock?: SystemLock, commitIfLost = true) {
18
- return function(req: Request, res: Response, next: NextFunction) {
25
+ /**
26
+ * Request a transaction gate. Returns a Promise that resolves when all currently
27
+ * active transactions have completed (drained). While waiting and after resolution,
28
+ * new withTransaction calls will be queued until releaseTransactionGate() is called.
29
+ *
30
+ * Use this before starting a long-running operation to ensure no other transactions
31
+ * are killed by PostgreSQL's idle_in_transaction_session_timeout while the long
32
+ * operation holds locks or occupies the connection pool.
33
+ *
34
+ * @returns A Promise that resolves when the gate is acquired.
35
+ */
36
+ export function requestTransactionGate(): Promise<void> {
37
+ if(withTransactionConfig.gateActive || withTransactionConfig.gateRequested) {
38
+ return Promise.reject(new Error('Transaction gate already active or requested'));
39
+ }
40
+ withTransactionConfig.gateRequested = true;
41
+ return new Promise<void>((resolve, _reject) => {
42
+ const check = () => {
43
+ // Allow the calling transaction to remain active (activeCount <= 1).
44
+ // When called from inside a withTransaction handler, the current
45
+ // transaction counts as 1. When called from outside (e.g. cron),
46
+ // activeCount may be 0.
47
+ if(withTransactionConfig.activeCount <= 1) {
48
+ withTransactionConfig.gateActive = true;
49
+ withTransactionConfig.gateRequested = false;
50
+ resolve();
51
+ } else {
52
+ // Poll every 100ms until other transactions drain
53
+ setTimeout(check, 100);
54
+ }
55
+ };
56
+ // Small initial delay to let in-flight transactions settle
57
+ setTimeout(check, 50);
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Release the transaction gate, allowing all queued transactions to proceed.
63
+ * Must be called after the long-running operation completes, in both success
64
+ * and error paths.
65
+ */
66
+ export function releaseTransactionGate(): void {
67
+ withTransactionConfig.gateActive = false;
68
+ withTransactionConfig.gateRequested = false;
69
+ // Drain the queue — resolve all pending waiters
70
+ const queue = withTransactionConfig.gateQueue.splice(0);
71
+ for(const entry of queue) {
72
+ entry.resolve();
73
+ }
74
+ }
75
+
76
+ function releaseAndDecrement(repo: BaseModelRepository, dbClient: any) {
77
+ if(repo.db && dbClient) {
78
+ try { dbClient.release(); } catch (_) { /* already released */ }
79
+ }
80
+ withTransactionConfig.activeCount = Math.max(0, withTransactionConfig.activeCount - 1);
81
+ }
82
+
83
+ export function withTransaction(repo: BaseModelRepository, logger: Logger, previousMethod: (req: Request, res: Response, next: NextFunction) => void,
84
+ lock?: SystemLock, commitIfLost = true, idleInTransactionTimeout?: number): (req: Request, res: Response, next: NextFunction) => any {
85
+ return function(this: any, req: Request, res: Response, next: NextFunction): any {
86
+ // If gate is active, queue this request and retry when gate is released
87
+ if(withTransactionConfig.gateActive) {
88
+ return new Promise<void>((resolve, reject) => {
89
+ withTransactionConfig.gateQueue.push({ resolve, reject });
90
+ }).then(() => {
91
+ // Re-enter with gate now released
92
+ return withTransaction(repo, logger, previousMethod, lock, commitIfLost)(req, res, next);
93
+ });
94
+ }
95
+
19
96
  let commit = true;
20
97
  if(!commitIfLost) {
21
98
  res.once('close', () => commit = false);
@@ -26,6 +103,10 @@ export function withTransaction(repo: BaseModelRepository, logger: Logger, previ
26
103
  res.write(jsonStringify(obj) || '{}');
27
104
  endTerminator();
28
105
  };
106
+
107
+ // Increment activeCount before attempting to connect to the database
108
+ withTransactionConfig.activeCount++;
109
+
29
110
  const connectTimeoutHandler = setTimeout(() => {
30
111
  // Timed out getting a client, restart worker or process...
31
112
  logger.error('Error timed out getting a client, exiting...');
@@ -56,7 +137,7 @@ export function withTransaction(repo: BaseModelRepository, logger: Logger, previ
56
137
  if(!res.locals.dbClientCommited) {
57
138
  res.locals.dbClientCommited = true;
58
139
  (commit ? (repo.db ? dbClient.query('COMMIT') : dbClient.commit()) : Promise.reject(new Error('Client lost'))).catch((err: any) => err).then((err: any) => {
59
- if(repo.db) dbClient.release();
140
+ releaseAndDecrement(repo, dbClient);
60
141
  if(!(err instanceof Error)) {
61
142
  for(let i = 0; i < res.locals.dbClientOnCommit.length; i++) {
62
143
  res.locals.dbClientOnCommit[i]();
@@ -69,7 +150,18 @@ export function withTransaction(repo: BaseModelRepository, logger: Logger, previ
69
150
  }
70
151
  };
71
152
  res.locals.dbClientOnCommit = [];
72
- return (repo.db ? dbClient.query('BEGIN') : dbClient.begin()).then(() => {
153
+
154
+ // Build the BEGIN + idle_in_transaction_session_timeout setup
155
+ const beginPromise = repo.db ? dbClient.query('BEGIN') : dbClient.begin();
156
+ const idleTimeoutMs = Math.max(idleInTransactionTimeout, withTransactionConfig.timeoutMillis);
157
+ let setupPromise: Promise<any> = beginPromise;
158
+ if(idleInTransactionTimeout && idleTimeoutMs > 0 && repo.db) {
159
+ setupPromise = setupPromise.then(() => {
160
+ return dbClient.query(`SET LOCAL idle_in_transaction_session_timeout TO ${idleTimeoutMs}`);
161
+ });
162
+ }
163
+
164
+ return setupPromise.then(() => {
73
165
  const finish = () => {
74
166
  res.send = () => {
75
167
  throw new Error('res.send() should not be used within transactions. Use res.json() instead.');
@@ -84,7 +176,7 @@ export function withTransaction(repo: BaseModelRepository, logger: Logger, previ
84
176
  }
85
177
  }
86
178
  (repo.db ? dbClient.query('ROLLBACK') : dbClient.rollback()).catch((err: any) => obj && obj.error && (obj.error.additionalInfo2 = {message: err.message})).then(() => {
87
- if(repo.db) dbClient.release();
179
+ releaseAndDecrement(repo, dbClient);
88
180
  jsonTerminator(obj);
89
181
  });
90
182
  } else {
@@ -108,7 +200,7 @@ export function withTransaction(repo: BaseModelRepository, logger: Logger, previ
108
200
  logger.error('Uncaught 500 with no details...');
109
201
  }
110
202
  (repo.db ? dbClient.query('ROLLBACK') : dbClient.rollback()).catch((): any => undefined).then(() => {
111
- if(repo.db) dbClient.release();
203
+ releaseAndDecrement(repo, dbClient);
112
204
  endTerminator();
113
205
  });
114
206
  } else {
@@ -137,19 +229,26 @@ export function withTransaction(repo: BaseModelRepository, logger: Logger, previ
137
229
  additionalInfo: {message: err.message}
138
230
  }
139
231
  });
140
- dbClient.release(); // Lock acquisition failed, release will also rollback
232
+ releaseAndDecrement(repo, dbClient); // Lock acquisition failed, release will also rollback
141
233
  });
142
234
  } else {
143
235
  finish();
144
236
  }
145
237
  }).catch((err: any) => {
146
238
  // Error beginning transaction
147
- dbClient.release();
148
- throw err;
239
+ releaseAndDecrement(repo, dbClient);
240
+ logger.error('Error beginning transaction: %j', err);
241
+ res.status(500).json({
242
+ error: {
243
+ errorKey: 'internal.db',
244
+ additionalInfo: {message: err.message}
245
+ }
246
+ });
149
247
  });
150
248
  }).catch(err => {
151
249
  // Error connecting to database for other reason than timeout or beginning transaction
152
250
  clearTimeout(connectTimeoutHandler);
251
+ withTransactionConfig.activeCount = Math.max(0, withTransactionConfig.activeCount - 1);
153
252
  res.status(500).json({
154
253
  error: {
155
254
  errorKey: 'internal.db',
package/services/utils.js CHANGED
@@ -236,6 +236,7 @@ function getHtml(template, translationKeyValue) {
236
236
  const replacer = (_, key) => replacePlaceholders(translationKeyValue, key.split('.'), 0) ?? key;
237
237
  template = template.replace(pattern, replacer);
238
238
  template = template.replace(pattern, replacer);
239
+ template = template.replace(pattern, replacer);
239
240
  template = template.replace(new RegExp('\\[\\[\\s*([a-zA-Z._0-9:]+)\\s*\\]\\]', 'g'), (_, key) => {
240
241
  const keyParts = key.split(':');
241
242
  return (replacePlaceholders(translationKeyValue, keyParts[1].split('.'), 0) || [])
package/services/utils.ts CHANGED
@@ -217,6 +217,7 @@ function getHtml(template: string, translationKeyValue: Record<string, any>): st
217
217
  const replacer = (_: string, key: string) => replacePlaceholders(translationKeyValue, key.split('.'), 0) ?? key;
218
218
  template = template.replace(pattern, replacer);
219
219
  template = template.replace(pattern, replacer);
220
+ template = template.replace(pattern, replacer);
220
221
  template = template.replace(new RegExp('\\[\\[\\s*([a-zA-Z._0-9:]+)\\s*\\]\\]', 'g'), (_, key) => {
221
222
  const keyParts = key.split(':');
222
223
  return (replacePlaceholders(translationKeyValue, keyParts[1].split('.'), 0) || [])