anote-server-libs 0.12.0 → 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/.vscode/settings.json +2 -2
- package/models/ApiCall.ts +40 -40
- package/models/Migration.ts +43 -43
- package/models/repository/CryptModelDao.js +82 -0
- package/models/repository/MemoryCache.ts +87 -87
- package/models/repository/ModelDao.ts +268 -268
- package/package.json +35 -35
- package/services/WithBody.ts +65 -65
- package/services/WithTransaction.ts +260 -260
- package/services/utils.js +25 -24
- package/services/utils.ts +289 -288
- package/tsconfig.json +29 -29
package/services/WithBody.ts
CHANGED
|
@@ -1,65 +1,65 @@
|
|
|
1
|
-
import {Request, Response} from 'express';
|
|
2
|
-
import Ajv, {_} from 'ajv';
|
|
3
|
-
|
|
4
|
-
export const ajv = new Ajv();
|
|
5
|
-
ajv.addKeyword({
|
|
6
|
-
keyword: 'maxDigits',
|
|
7
|
-
type: 'number',
|
|
8
|
-
schemaType: 'number',
|
|
9
|
-
validate: (schema: number, data: any) => typeof data === 'number' && Math.round(data * 10 ** schema) / (10 ** schema) === data
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
export function WithBody(schema: any, collapseTopLevelNulls = true) {
|
|
13
|
-
if(schema.type === 'object') {
|
|
14
|
-
const required: string[] = [];
|
|
15
|
-
const keys = Object.getOwnPropertyNames(schema.properties);
|
|
16
|
-
keys.forEach(key => {
|
|
17
|
-
if(schema.properties[key].required === true) {
|
|
18
|
-
required.push(key);
|
|
19
|
-
}
|
|
20
|
-
if(schema.properties[key].required === true || schema.properties[key].required === false) {
|
|
21
|
-
delete schema.properties[key].required;
|
|
22
|
-
}
|
|
23
|
-
});
|
|
24
|
-
schema.required = required;
|
|
25
|
-
}
|
|
26
|
-
const validate = ajv.compile(schema);
|
|
27
|
-
return function(_: any, __: string, descriptor: PropertyDescriptor) {
|
|
28
|
-
if(typeof descriptor.value === 'function') {
|
|
29
|
-
const previousMethod = descriptor.value;
|
|
30
|
-
descriptor.value = function(this: any, req: Request, res: Response) {
|
|
31
|
-
if(req.method.toUpperCase() !== 'POST' && req.method.toUpperCase() !== 'PUT') {
|
|
32
|
-
return previousMethod.call(this, req, res);
|
|
33
|
-
}
|
|
34
|
-
if(!req.body) {
|
|
35
|
-
res.status(400).json({
|
|
36
|
-
error: {
|
|
37
|
-
errorKey: 'client.body.missing',
|
|
38
|
-
additionalInfo: 'client.extended.badPayload'
|
|
39
|
-
}
|
|
40
|
-
});
|
|
41
|
-
} else {
|
|
42
|
-
if(collapseTopLevelNulls) {
|
|
43
|
-
Object.keys(req.body).forEach(k => {
|
|
44
|
-
if(req.body[k] === null) delete req.body[k];
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
const valid = validate(req.body);
|
|
48
|
-
if(valid) {
|
|
49
|
-
return previousMethod.call(this, req, res);
|
|
50
|
-
} else {
|
|
51
|
-
res.status(400).json({
|
|
52
|
-
error: {
|
|
53
|
-
errorKey: 'client.body.missing',
|
|
54
|
-
additionalInfo: 'client.extended.badPayload',
|
|
55
|
-
detailedInfo: validate.errors
|
|
56
|
-
}
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
};
|
|
61
|
-
return descriptor;
|
|
62
|
-
}
|
|
63
|
-
return undefined;
|
|
64
|
-
};
|
|
65
|
-
}
|
|
1
|
+
import {Request, Response} from 'express';
|
|
2
|
+
import Ajv, {_} from 'ajv';
|
|
3
|
+
|
|
4
|
+
export const ajv = new Ajv();
|
|
5
|
+
ajv.addKeyword({
|
|
6
|
+
keyword: 'maxDigits',
|
|
7
|
+
type: 'number',
|
|
8
|
+
schemaType: 'number',
|
|
9
|
+
validate: (schema: number, data: any) => typeof data === 'number' && Math.round(data * 10 ** schema) / (10 ** schema) === data
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export function WithBody(schema: any, collapseTopLevelNulls = true) {
|
|
13
|
+
if(schema.type === 'object') {
|
|
14
|
+
const required: string[] = [];
|
|
15
|
+
const keys = Object.getOwnPropertyNames(schema.properties);
|
|
16
|
+
keys.forEach(key => {
|
|
17
|
+
if(schema.properties[key].required === true) {
|
|
18
|
+
required.push(key);
|
|
19
|
+
}
|
|
20
|
+
if(schema.properties[key].required === true || schema.properties[key].required === false) {
|
|
21
|
+
delete schema.properties[key].required;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
schema.required = required;
|
|
25
|
+
}
|
|
26
|
+
const validate = ajv.compile(schema);
|
|
27
|
+
return function(_: any, __: string, descriptor: PropertyDescriptor) {
|
|
28
|
+
if(typeof descriptor.value === 'function') {
|
|
29
|
+
const previousMethod = descriptor.value;
|
|
30
|
+
descriptor.value = function(this: any, req: Request, res: Response) {
|
|
31
|
+
if(req.method.toUpperCase() !== 'POST' && req.method.toUpperCase() !== 'PUT') {
|
|
32
|
+
return previousMethod.call(this, req, res);
|
|
33
|
+
}
|
|
34
|
+
if(!req.body) {
|
|
35
|
+
res.status(400).json({
|
|
36
|
+
error: {
|
|
37
|
+
errorKey: 'client.body.missing',
|
|
38
|
+
additionalInfo: 'client.extended.badPayload'
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
} else {
|
|
42
|
+
if(collapseTopLevelNulls) {
|
|
43
|
+
Object.keys(req.body).forEach(k => {
|
|
44
|
+
if(req.body[k] === null) delete req.body[k];
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const valid = validate(req.body);
|
|
48
|
+
if(valid) {
|
|
49
|
+
return previousMethod.call(this, req, res);
|
|
50
|
+
} else {
|
|
51
|
+
res.status(400).json({
|
|
52
|
+
error: {
|
|
53
|
+
errorKey: 'client.body.missing',
|
|
54
|
+
additionalInfo: 'client.extended.badPayload',
|
|
55
|
+
detailedInfo: validate.errors
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
return descriptor;
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -1,260 +1,260 @@
|
|
|
1
|
-
import {NextFunction, Request, Response} from 'express';
|
|
2
|
-
import {Logger} from 'winston';
|
|
3
|
-
import {BaseModelRepository} from '../models/repository/BaseModelRepository';
|
|
4
|
-
import {jsonStringify, utils} from './utils';
|
|
5
|
-
|
|
6
|
-
export const enum SystemLock {
|
|
7
|
-
CHECK_CROSSING = 1,
|
|
8
|
-
FLUSH_CALLS = 2,
|
|
9
|
-
TX_CHECK = 3
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export const withTransactionConfig = {
|
|
13
|
-
logQueries: false,
|
|
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}>,
|
|
23
|
-
};
|
|
24
|
-
|
|
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
|
-
|
|
96
|
-
let commit = true;
|
|
97
|
-
if(!commitIfLost) {
|
|
98
|
-
res.once('close', () => commit = false);
|
|
99
|
-
}
|
|
100
|
-
const endTerminator = res.end.bind(res);
|
|
101
|
-
const jsonTerminator = (obj: any) => {
|
|
102
|
-
try { res.set('Content-Type', 'application/json'); } catch(_) {}
|
|
103
|
-
res.write(jsonStringify(obj) || '{}');
|
|
104
|
-
endTerminator();
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
// Increment activeCount before attempting to connect to the database
|
|
108
|
-
withTransactionConfig.activeCount++;
|
|
109
|
-
|
|
110
|
-
const connectTimeoutHandler = setTimeout(() => {
|
|
111
|
-
// Timed out getting a client, restart worker or process...
|
|
112
|
-
logger.error('Error timed out getting a client, exiting...');
|
|
113
|
-
process.exit(22);
|
|
114
|
-
}, withTransactionConfig.timeoutMillis);
|
|
115
|
-
Promise.all([
|
|
116
|
-
repo.db ? repo.db.connect() : Promise.resolve(undefined),
|
|
117
|
-
repo.dbMssql ? Promise.resolve(repo.dbMssql.transaction()) : Promise.resolve(undefined)
|
|
118
|
-
]).then(([c1, c2]) => {
|
|
119
|
-
const dbClient = c1 || c2;
|
|
120
|
-
clearTimeout(connectTimeoutHandler);
|
|
121
|
-
// On error, will rollback...
|
|
122
|
-
utils.logger = logger;
|
|
123
|
-
if(withTransactionConfig.logQueries && !dbClient.queryPatched) {
|
|
124
|
-
const originalQuery = dbClient.query.bind(dbClient);
|
|
125
|
-
dbClient.query = (...args: any[]) => {
|
|
126
|
-
logger.debug('SQL [Client %d] QUERY: %s', dbClient.processID, args[0]);
|
|
127
|
-
return originalQuery(...args);
|
|
128
|
-
};
|
|
129
|
-
dbClient.queryPatched = true;
|
|
130
|
-
}
|
|
131
|
-
dbClient.removeAllListeners('error');
|
|
132
|
-
dbClient.on('error', (err: any) => utils.clientErrorHandler(err, dbClient));
|
|
133
|
-
|
|
134
|
-
res.locals.dbClient = dbClient;
|
|
135
|
-
res.locals.dbClientCommited = false;
|
|
136
|
-
res.locals.dbClientCommit = (cb: (err: any) => any) => {
|
|
137
|
-
if(!res.locals.dbClientCommited) {
|
|
138
|
-
res.locals.dbClientCommited = true;
|
|
139
|
-
(commit ? (repo.db ? dbClient.query('COMMIT') : dbClient.commit()) : Promise.reject(new Error('Client lost'))).catch((err: any) => err).then((err: any) => {
|
|
140
|
-
releaseAndDecrement(repo, dbClient);
|
|
141
|
-
if(!(err instanceof Error)) {
|
|
142
|
-
for(let i = 0; i < res.locals.dbClientOnCommit.length; i++) {
|
|
143
|
-
res.locals.dbClientOnCommit[i]();
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
cb(err instanceof Error ? err : undefined);
|
|
147
|
-
});
|
|
148
|
-
} else {
|
|
149
|
-
cb(undefined);
|
|
150
|
-
}
|
|
151
|
-
};
|
|
152
|
-
res.locals.dbClientOnCommit = [];
|
|
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(() => {
|
|
165
|
-
const finish = () => {
|
|
166
|
-
res.send = () => {
|
|
167
|
-
throw new Error('res.send() should not be used within transactions. Use res.json() instead.');
|
|
168
|
-
};
|
|
169
|
-
res.json = (obj: any) => {
|
|
170
|
-
if(res.statusCode > 303 && res.statusCode !== 412) {
|
|
171
|
-
if(logger) {
|
|
172
|
-
if(res.statusCode > 499) {
|
|
173
|
-
logger.error('Uncaught 500: %j', obj?.error?.additionalInfo);
|
|
174
|
-
} else {
|
|
175
|
-
logger.warn('Client error 4XX: %j', obj?.error);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
(repo.db ? dbClient.query('ROLLBACK') : dbClient.rollback()).catch((err: any) => obj && obj.error && (obj.error.additionalInfo2 = {message: err.message})).then(() => {
|
|
179
|
-
releaseAndDecrement(repo, dbClient);
|
|
180
|
-
jsonTerminator(obj);
|
|
181
|
-
});
|
|
182
|
-
} else {
|
|
183
|
-
res.locals.dbClientCommit((err: any) => {
|
|
184
|
-
if(err && commitIfLost) {
|
|
185
|
-
res.status(500);
|
|
186
|
-
jsonTerminator({
|
|
187
|
-
error: {
|
|
188
|
-
errorKey: 'internal.db',
|
|
189
|
-
additionalInfo: {message: err.message}
|
|
190
|
-
}
|
|
191
|
-
});
|
|
192
|
-
} else jsonTerminator(obj);
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
return res;
|
|
196
|
-
};
|
|
197
|
-
res.end = () => {
|
|
198
|
-
if(res.statusCode > 303 && res.statusCode !== 412) {
|
|
199
|
-
if(logger && res.statusCode > 499) {
|
|
200
|
-
logger.error('Uncaught 500 with no details...');
|
|
201
|
-
}
|
|
202
|
-
(repo.db ? dbClient.query('ROLLBACK') : dbClient.rollback()).catch((): any => undefined).then(() => {
|
|
203
|
-
releaseAndDecrement(repo, dbClient);
|
|
204
|
-
endTerminator();
|
|
205
|
-
});
|
|
206
|
-
} else {
|
|
207
|
-
res.locals.dbClientCommit((err: any) => {
|
|
208
|
-
if(err && commitIfLost) {
|
|
209
|
-
res.status(500);
|
|
210
|
-
jsonTerminator({
|
|
211
|
-
error: {
|
|
212
|
-
errorKey: 'internal.db',
|
|
213
|
-
additionalInfo: {message: err.message}
|
|
214
|
-
}
|
|
215
|
-
});
|
|
216
|
-
} else endTerminator();
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
return res;
|
|
220
|
-
};
|
|
221
|
-
return previousMethod.call(this, req, res, next);
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
if(lock) {
|
|
225
|
-
dbClient.query('SELECT pg_advisory_xact_lock(' + lock + ')').then(() => finish()).catch((err: any) => {
|
|
226
|
-
res.status(500).json({
|
|
227
|
-
error: {
|
|
228
|
-
errorKey: 'internal.db',
|
|
229
|
-
additionalInfo: {message: err.message}
|
|
230
|
-
}
|
|
231
|
-
});
|
|
232
|
-
releaseAndDecrement(repo, dbClient); // Lock acquisition failed, release will also rollback
|
|
233
|
-
});
|
|
234
|
-
} else {
|
|
235
|
-
finish();
|
|
236
|
-
}
|
|
237
|
-
}).catch((err: any) => {
|
|
238
|
-
// Error beginning transaction
|
|
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
|
-
});
|
|
247
|
-
});
|
|
248
|
-
}).catch(err => {
|
|
249
|
-
// Error connecting to database for other reason than timeout or beginning transaction
|
|
250
|
-
clearTimeout(connectTimeoutHandler);
|
|
251
|
-
withTransactionConfig.activeCount = Math.max(0, withTransactionConfig.activeCount - 1);
|
|
252
|
-
res.status(500).json({
|
|
253
|
-
error: {
|
|
254
|
-
errorKey: 'internal.db',
|
|
255
|
-
additionalInfo: {message: err.message}
|
|
256
|
-
}
|
|
257
|
-
});
|
|
258
|
-
});
|
|
259
|
-
};
|
|
260
|
-
}
|
|
1
|
+
import {NextFunction, Request, Response} from 'express';
|
|
2
|
+
import {Logger} from 'winston';
|
|
3
|
+
import {BaseModelRepository} from '../models/repository/BaseModelRepository';
|
|
4
|
+
import {jsonStringify, utils} from './utils';
|
|
5
|
+
|
|
6
|
+
export const enum SystemLock {
|
|
7
|
+
CHECK_CROSSING = 1,
|
|
8
|
+
FLUSH_CALLS = 2,
|
|
9
|
+
TX_CHECK = 3
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const withTransactionConfig = {
|
|
13
|
+
logQueries: false,
|
|
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}>,
|
|
23
|
+
};
|
|
24
|
+
|
|
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
|
+
|
|
96
|
+
let commit = true;
|
|
97
|
+
if(!commitIfLost) {
|
|
98
|
+
res.once('close', () => commit = false);
|
|
99
|
+
}
|
|
100
|
+
const endTerminator = res.end.bind(res);
|
|
101
|
+
const jsonTerminator = (obj: any) => {
|
|
102
|
+
try { res.set('Content-Type', 'application/json'); } catch(_) {}
|
|
103
|
+
res.write(jsonStringify(obj) || '{}');
|
|
104
|
+
endTerminator();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Increment activeCount before attempting to connect to the database
|
|
108
|
+
withTransactionConfig.activeCount++;
|
|
109
|
+
|
|
110
|
+
const connectTimeoutHandler = setTimeout(() => {
|
|
111
|
+
// Timed out getting a client, restart worker or process...
|
|
112
|
+
logger.error('Error timed out getting a client, exiting...');
|
|
113
|
+
process.exit(22);
|
|
114
|
+
}, withTransactionConfig.timeoutMillis);
|
|
115
|
+
Promise.all([
|
|
116
|
+
repo.db ? repo.db.connect() : Promise.resolve(undefined),
|
|
117
|
+
repo.dbMssql ? Promise.resolve(repo.dbMssql.transaction()) : Promise.resolve(undefined)
|
|
118
|
+
]).then(([c1, c2]) => {
|
|
119
|
+
const dbClient = c1 || c2;
|
|
120
|
+
clearTimeout(connectTimeoutHandler);
|
|
121
|
+
// On error, will rollback...
|
|
122
|
+
utils.logger = logger;
|
|
123
|
+
if(withTransactionConfig.logQueries && !dbClient.queryPatched) {
|
|
124
|
+
const originalQuery = dbClient.query.bind(dbClient);
|
|
125
|
+
dbClient.query = (...args: any[]) => {
|
|
126
|
+
logger.debug('SQL [Client %d] QUERY: %s', dbClient.processID, args[0]);
|
|
127
|
+
return originalQuery(...args);
|
|
128
|
+
};
|
|
129
|
+
dbClient.queryPatched = true;
|
|
130
|
+
}
|
|
131
|
+
dbClient.removeAllListeners('error');
|
|
132
|
+
dbClient.on('error', (err: any) => utils.clientErrorHandler(err, dbClient));
|
|
133
|
+
|
|
134
|
+
res.locals.dbClient = dbClient;
|
|
135
|
+
res.locals.dbClientCommited = false;
|
|
136
|
+
res.locals.dbClientCommit = (cb: (err: any) => any) => {
|
|
137
|
+
if(!res.locals.dbClientCommited) {
|
|
138
|
+
res.locals.dbClientCommited = true;
|
|
139
|
+
(commit ? (repo.db ? dbClient.query('COMMIT') : dbClient.commit()) : Promise.reject(new Error('Client lost'))).catch((err: any) => err).then((err: any) => {
|
|
140
|
+
releaseAndDecrement(repo, dbClient);
|
|
141
|
+
if(!(err instanceof Error)) {
|
|
142
|
+
for(let i = 0; i < res.locals.dbClientOnCommit.length; i++) {
|
|
143
|
+
res.locals.dbClientOnCommit[i]();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
cb(err instanceof Error ? err : undefined);
|
|
147
|
+
});
|
|
148
|
+
} else {
|
|
149
|
+
cb(undefined);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
res.locals.dbClientOnCommit = [];
|
|
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(() => {
|
|
165
|
+
const finish = () => {
|
|
166
|
+
res.send = () => {
|
|
167
|
+
throw new Error('res.send() should not be used within transactions. Use res.json() instead.');
|
|
168
|
+
};
|
|
169
|
+
res.json = (obj: any) => {
|
|
170
|
+
if(res.statusCode > 303 && res.statusCode !== 412) {
|
|
171
|
+
if(logger) {
|
|
172
|
+
if(res.statusCode > 499) {
|
|
173
|
+
logger.error('Uncaught 500: %j', obj?.error?.additionalInfo);
|
|
174
|
+
} else {
|
|
175
|
+
logger.warn('Client error 4XX: %j', obj?.error);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
(repo.db ? dbClient.query('ROLLBACK') : dbClient.rollback()).catch((err: any) => obj && obj.error && (obj.error.additionalInfo2 = {message: err.message})).then(() => {
|
|
179
|
+
releaseAndDecrement(repo, dbClient);
|
|
180
|
+
jsonTerminator(obj);
|
|
181
|
+
});
|
|
182
|
+
} else {
|
|
183
|
+
res.locals.dbClientCommit((err: any) => {
|
|
184
|
+
if(err && commitIfLost) {
|
|
185
|
+
res.status(500);
|
|
186
|
+
jsonTerminator({
|
|
187
|
+
error: {
|
|
188
|
+
errorKey: 'internal.db',
|
|
189
|
+
additionalInfo: {message: err.message}
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
} else jsonTerminator(obj);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return res;
|
|
196
|
+
};
|
|
197
|
+
res.end = () => {
|
|
198
|
+
if(res.statusCode > 303 && res.statusCode !== 412) {
|
|
199
|
+
if(logger && res.statusCode > 499) {
|
|
200
|
+
logger.error('Uncaught 500 with no details...');
|
|
201
|
+
}
|
|
202
|
+
(repo.db ? dbClient.query('ROLLBACK') : dbClient.rollback()).catch((): any => undefined).then(() => {
|
|
203
|
+
releaseAndDecrement(repo, dbClient);
|
|
204
|
+
endTerminator();
|
|
205
|
+
});
|
|
206
|
+
} else {
|
|
207
|
+
res.locals.dbClientCommit((err: any) => {
|
|
208
|
+
if(err && commitIfLost) {
|
|
209
|
+
res.status(500);
|
|
210
|
+
jsonTerminator({
|
|
211
|
+
error: {
|
|
212
|
+
errorKey: 'internal.db',
|
|
213
|
+
additionalInfo: {message: err.message}
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
} else endTerminator();
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return res;
|
|
220
|
+
};
|
|
221
|
+
return previousMethod.call(this, req, res, next);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
if(lock) {
|
|
225
|
+
dbClient.query('SELECT pg_advisory_xact_lock(' + lock + ')').then(() => finish()).catch((err: any) => {
|
|
226
|
+
res.status(500).json({
|
|
227
|
+
error: {
|
|
228
|
+
errorKey: 'internal.db',
|
|
229
|
+
additionalInfo: {message: err.message}
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
releaseAndDecrement(repo, dbClient); // Lock acquisition failed, release will also rollback
|
|
233
|
+
});
|
|
234
|
+
} else {
|
|
235
|
+
finish();
|
|
236
|
+
}
|
|
237
|
+
}).catch((err: any) => {
|
|
238
|
+
// Error beginning transaction
|
|
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
|
+
});
|
|
247
|
+
});
|
|
248
|
+
}).catch(err => {
|
|
249
|
+
// Error connecting to database for other reason than timeout or beginning transaction
|
|
250
|
+
clearTimeout(connectTimeoutHandler);
|
|
251
|
+
withTransactionConfig.activeCount = Math.max(0, withTransactionConfig.activeCount - 1);
|
|
252
|
+
res.status(500).json({
|
|
253
|
+
error: {
|
|
254
|
+
errorKey: 'internal.db',
|
|
255
|
+
additionalInfo: {message: err.message}
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
};
|
|
260
|
+
}
|