opencons 0.1.0
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/LICENSE +21 -0
- package/README.md +382 -0
- package/opencons.d.ts +55 -0
- package/package.json +73 -0
- package/scripts/vendor-d3.js +22 -0
- package/src/core/context.js +44 -0
- package/src/core/index.js +198 -0
- package/src/core/tracer.js +252 -0
- package/src/drivers/db-language.js +207 -0
- package/src/drivers/detect.js +62 -0
- package/src/drivers/drizzle.js +87 -0
- package/src/drivers/index.js +43 -0
- package/src/drivers/mongoose.js +89 -0
- package/src/drivers/mysql2.js +116 -0
- package/src/drivers/pg.js +130 -0
- package/src/drivers/prisma.js +109 -0
- package/src/drivers/record.js +158 -0
- package/src/index.js +28 -0
- package/src/integrations/nest-lifecycle.js +357 -0
- package/src/integrations/nest.js +89 -0
- package/src/interceptors/express.js +270 -0
- package/src/interceptors/require-hook.js +109 -0
- package/src/lib/config.js +139 -0
- package/src/lib/errors.js +54 -0
- package/src/lib/http-response.js +37 -0
- package/src/lib/logger.js +69 -0
- package/src/lib/serialize.js +22 -0
- package/src/server/static.js +165 -0
- package/src/server/ws.js +62 -0
- package/src/store/source-cache.js +120 -0
- package/src/store/trace-store.js +117 -0
- package/src/transform/ast.js +255 -0
- package/src/transform/natural-language.js +146 -0
- package/src/transform/probe.js +161 -0
- package/src/transform/register.js +44 -0
- package/src/utils/label.js +26 -0
- package/src/utils/observable.js +103 -0
- package/widget/app.js +356 -0
- package/widget/db-language.js +90 -0
- package/widget/graph.js +1167 -0
- package/widget/index.html +132 -0
- package/widget/styles.css +773 -0
- package/widget/timeline.js +57 -0
- package/widget/vendor/d3.min.js +2 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { runWithContext, getCurrentContext } = require('./context');
|
|
4
|
+
const { TraceTracer } = require('./tracer');
|
|
5
|
+
const { createTraceStore } = require('../store/trace-store');
|
|
6
|
+
const { createWebSocketServer } = require('../server/ws');
|
|
7
|
+
const { createStaticServer } = require('../server/static');
|
|
8
|
+
const { installRequireHook } = require('../interceptors/require-hook');
|
|
9
|
+
const { installDrivers } = require('../drivers');
|
|
10
|
+
const { resolveOptions, isProductionDisabled } = require('../lib/config');
|
|
11
|
+
const { logger } = require('../lib/logger');
|
|
12
|
+
const { safeClone } = require('../lib/serialize');
|
|
13
|
+
|
|
14
|
+
/** @typedef {import('../lib/config').OpenconsOptions} OpenconsOptions */
|
|
15
|
+
|
|
16
|
+
/** @type {ReturnType<import('../store/trace-store').createTraceStore> | null} */
|
|
17
|
+
let traceStore = null;
|
|
18
|
+
|
|
19
|
+
/** @type {boolean} */
|
|
20
|
+
let initialised = false;
|
|
21
|
+
|
|
22
|
+
/** @type {OpenconsOptions | null} */
|
|
23
|
+
let activeOptions = null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {Partial<OpenconsOptions>} [userOptions]
|
|
27
|
+
* @returns {import('express').RequestHandler}
|
|
28
|
+
*/
|
|
29
|
+
function createOpencons(userOptions = {}) {
|
|
30
|
+
let options;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
options = resolveOptions(userOptions);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
logger.error(`Invalid configuration: ${err.message}`);
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (isProductionDisabled(options)) {
|
|
40
|
+
logger.warn('Disabled in production. Opencons is intended for development only.');
|
|
41
|
+
return (_req, _res, next) => next();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (initialised) {
|
|
45
|
+
if (activeOptions && JSON.stringify(activeOptions) !== JSON.stringify(options)) {
|
|
46
|
+
logger.warn('Already initialised — additional options are ignored. Call opencons() once.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return buildMiddleware(options);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
initialised = true;
|
|
53
|
+
activeOptions = options;
|
|
54
|
+
traceStore = createTraceStore(options.maxTraces);
|
|
55
|
+
|
|
56
|
+
if (options.transform?.enabled) {
|
|
57
|
+
installRequireHook({
|
|
58
|
+
projectRoot: options.transform.projectRoot || process.cwd(),
|
|
59
|
+
exclude: options.transform.exclude || [],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
installDrivers(options.drivers);
|
|
64
|
+
|
|
65
|
+
if (options.enableWidget) {
|
|
66
|
+
createStaticServer(options.port)
|
|
67
|
+
.then(({ port }) => {
|
|
68
|
+
options.widgetPort = port;
|
|
69
|
+
createWebSocketServer(traceStore);
|
|
70
|
+
})
|
|
71
|
+
.catch((err) => {
|
|
72
|
+
logger.error(`Widget server failed to start: ${err.message}`, err);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return buildMiddleware(options);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {OpenconsOptions} options
|
|
81
|
+
* @returns {import('express').RequestHandler}
|
|
82
|
+
*/
|
|
83
|
+
function buildMiddleware(options) {
|
|
84
|
+
let loggedFirstRequest = false;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {import('express').Request} req
|
|
88
|
+
* @param {import('express').Response} res
|
|
89
|
+
* @param {import('express').NextFunction} next
|
|
90
|
+
*/
|
|
91
|
+
function middleware(req, res, next) {
|
|
92
|
+
if (!traceStore) {
|
|
93
|
+
return next();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Nest hook + prepend can both invoke this — trace once per request.
|
|
97
|
+
if (getCurrentContext()) {
|
|
98
|
+
return next();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (shouldExclude(req, options.exclude)) {
|
|
102
|
+
return next();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!loggedFirstRequest) {
|
|
106
|
+
loggedFirstRequest = true;
|
|
107
|
+
logger.info(`Tracing ${req.method} ${req.originalUrl || req.url}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @type {unknown} */
|
|
111
|
+
let capturedResponse;
|
|
112
|
+
|
|
113
|
+
if (options.captureResponse) {
|
|
114
|
+
attachResponseCapture(res, (body) => {
|
|
115
|
+
capturedResponse = safeClone(body);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const tracer = new TraceTracer({
|
|
120
|
+
method: req.method,
|
|
121
|
+
url: req.originalUrl || req.url,
|
|
122
|
+
params: req.params,
|
|
123
|
+
body: options.captureBody ? safeClone(req.body) : undefined,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
tracer.onChange = () => {
|
|
127
|
+
traceStore.update(tracer.snapshot());
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
traceStore.start(tracer.snapshot());
|
|
131
|
+
|
|
132
|
+
const context = {
|
|
133
|
+
id: tracer.id,
|
|
134
|
+
startTime: tracer.startTime,
|
|
135
|
+
tracer,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
let finished = false;
|
|
139
|
+
|
|
140
|
+
const onFinish = () => {
|
|
141
|
+
if (finished) return;
|
|
142
|
+
finished = true;
|
|
143
|
+
tracer.onChange = null;
|
|
144
|
+
traceStore.complete(tracer.finish(res.statusCode, capturedResponse));
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
res.on('finish', onFinish);
|
|
148
|
+
res.on('close', onFinish);
|
|
149
|
+
|
|
150
|
+
runWithContext(context, () => next());
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
middleware.getTraces = () => (traceStore ? traceStore.getAll() : []);
|
|
154
|
+
middleware.options = options;
|
|
155
|
+
middleware.__openconsEntry = true;
|
|
156
|
+
|
|
157
|
+
return middleware;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @param {import('express').Response} res
|
|
162
|
+
* @param {(body: unknown) => void} onCapture
|
|
163
|
+
*/
|
|
164
|
+
function attachResponseCapture(res, onCapture) {
|
|
165
|
+
const originalJson = res.json.bind(res);
|
|
166
|
+
res.json = function patchedJson(body) {
|
|
167
|
+
onCapture(body);
|
|
168
|
+
return originalJson(body);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const originalSend = res.send.bind(res);
|
|
172
|
+
res.send = function patchedSend(body) {
|
|
173
|
+
onCapture(body);
|
|
174
|
+
return originalSend(body);
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param {import('express').Request} req
|
|
180
|
+
* @param {string[]} excludePatterns
|
|
181
|
+
*/
|
|
182
|
+
function shouldExclude(req, excludePatterns) {
|
|
183
|
+
const path = req.originalUrl || req.url;
|
|
184
|
+
|
|
185
|
+
return excludePatterns.some((pattern) => {
|
|
186
|
+
if (pattern.includes('*')) {
|
|
187
|
+
const regex = new RegExp(
|
|
188
|
+
`^${pattern.replace(/\*/g, '.*').replace(/\//g, '\\/')}$`
|
|
189
|
+
);
|
|
190
|
+
return regex.test(path.split('?')[0]);
|
|
191
|
+
}
|
|
192
|
+
return path.startsWith(pattern);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = {
|
|
197
|
+
createOpencons,
|
|
198
|
+
};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { randomBytes } = require('crypto');
|
|
4
|
+
const { getCurrentContext } = require('./context');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {'request' | 'middleware' | 'controller' | 'branch' | 'loop' | 'db' | 'response' | 'error'} NodeType
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} TraceNode
|
|
12
|
+
* @property {string} id
|
|
13
|
+
* @property {NodeType} type
|
|
14
|
+
* @property {string} label
|
|
15
|
+
* @property {string} [summary]
|
|
16
|
+
* @property {string} [condition]
|
|
17
|
+
* @property {boolean} [has_else]
|
|
18
|
+
* @property {{ key: string, label: string, taken: boolean }[]} [outcomes]
|
|
19
|
+
* @property {string | null} [taken_outcome]
|
|
20
|
+
* @property {number | null} duration_ms
|
|
21
|
+
* @property {boolean} [called_next]
|
|
22
|
+
* @property {string} [exit_reason]
|
|
23
|
+
* @property {*} [value]
|
|
24
|
+
* @property {number} [rows]
|
|
25
|
+
* @property {string} [query]
|
|
26
|
+
* @property {unknown} [params]
|
|
27
|
+
* @property {string} [driver]
|
|
28
|
+
* @property {string} [operation]
|
|
29
|
+
* @property {string} [collection]
|
|
30
|
+
* @property {'select' | 'insert' | 'update' | 'delete' | 'count' | 'transaction' | 'query'} [db_action]
|
|
31
|
+
* @property {string} [db_intent]
|
|
32
|
+
* @property {string} [db_result]
|
|
33
|
+
* @property {{ file: string, line: number | null, kind?: string }} [source]
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {Object} TraceEdge
|
|
38
|
+
* @property {string} from
|
|
39
|
+
* @property {string} to
|
|
40
|
+
* @property {boolean} [parallel]
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {Object} TraceGraph
|
|
45
|
+
* @property {string} id
|
|
46
|
+
* @property {number} timestamp
|
|
47
|
+
* @property {string} method
|
|
48
|
+
* @property {string} url
|
|
49
|
+
* @property {Record<string, string>} params
|
|
50
|
+
* @property {unknown} [body]
|
|
51
|
+
* @property {unknown} [response]
|
|
52
|
+
* @property {number | null} status
|
|
53
|
+
* @property {number} duration_ms
|
|
54
|
+
* @property {'active' | 'complete'} [state]
|
|
55
|
+
* @property {TraceNode[]} nodes
|
|
56
|
+
* @property {TraceEdge[]} edges
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
class TraceTracer {
|
|
60
|
+
/**
|
|
61
|
+
* @param {Object} meta
|
|
62
|
+
* @param {string} meta.method
|
|
63
|
+
* @param {string} meta.url
|
|
64
|
+
* @param {Record<string, string>} [meta.params]
|
|
65
|
+
* @param {unknown} [meta.body]
|
|
66
|
+
*/
|
|
67
|
+
constructor(meta) {
|
|
68
|
+
this.id = `req_${randomBytes(4).toString('hex')}`;
|
|
69
|
+
this.timestamp = Date.now();
|
|
70
|
+
this.method = meta.method;
|
|
71
|
+
this.url = meta.url;
|
|
72
|
+
this.params = meta.params || {};
|
|
73
|
+
this.body = meta.body;
|
|
74
|
+
this.status = null;
|
|
75
|
+
this.startTime = performance.now();
|
|
76
|
+
this._nodeCounter = 0;
|
|
77
|
+
this._lastNodeId = null;
|
|
78
|
+
|
|
79
|
+
const requestNode = this._createNode({
|
|
80
|
+
type: 'request',
|
|
81
|
+
label: `${meta.method} ${meta.url}`,
|
|
82
|
+
duration_ms: null,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
this.nodes = [requestNode];
|
|
86
|
+
this.edges = [];
|
|
87
|
+
this._lastNodeId = requestNode.id;
|
|
88
|
+
this._finished = false;
|
|
89
|
+
|
|
90
|
+
/** @type {(() => void) | null} */
|
|
91
|
+
this.onChange = null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_nextNodeId() {
|
|
95
|
+
this._nodeCounter += 1;
|
|
96
|
+
return `n${this._nodeCounter}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {Omit<TraceNode, 'id'>} nodeData
|
|
101
|
+
* @returns {TraceNode}
|
|
102
|
+
*/
|
|
103
|
+
_createNode(nodeData) {
|
|
104
|
+
return {
|
|
105
|
+
id: this._nextNodeId(),
|
|
106
|
+
...nodeData,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {Omit<TraceNode, 'id'>} nodeData
|
|
112
|
+
* @returns {TraceNode}
|
|
113
|
+
*/
|
|
114
|
+
addNode(nodeData) {
|
|
115
|
+
const node = this._createNode(nodeData);
|
|
116
|
+
this.nodes.push(node);
|
|
117
|
+
|
|
118
|
+
if (this._lastNodeId) {
|
|
119
|
+
this.edges.push({ from: this._lastNodeId, to: node.id });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
this._lastNodeId = node.id;
|
|
123
|
+
this._setScopeNode(node);
|
|
124
|
+
this._notifyChange();
|
|
125
|
+
return node;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Record a concurrent branch (e.g. database query) from the active handler scope.
|
|
130
|
+
* @param {string | null | undefined} parentId
|
|
131
|
+
* @param {Omit<TraceNode, 'id'>} nodeData
|
|
132
|
+
* @returns {TraceNode}
|
|
133
|
+
*/
|
|
134
|
+
addForkNode(parentId, nodeData) {
|
|
135
|
+
const node = this._createNode(nodeData);
|
|
136
|
+
const from = parentId || this._lastNodeId;
|
|
137
|
+
|
|
138
|
+
this.nodes.push(node);
|
|
139
|
+
|
|
140
|
+
if (from) {
|
|
141
|
+
this.edges.push({ from, to: node.id, parallel: true });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
this._notifyChange();
|
|
145
|
+
return node;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {string} nodeId
|
|
150
|
+
* @param {Partial<TraceNode>} patch
|
|
151
|
+
*/
|
|
152
|
+
updateNode(nodeId, patch) {
|
|
153
|
+
const node = this.nodes.find((entry) => entry.id === nodeId);
|
|
154
|
+
if (!node) return;
|
|
155
|
+
Object.assign(node, patch);
|
|
156
|
+
this._notifyChange();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @returns {string | null}
|
|
161
|
+
*/
|
|
162
|
+
getLastSequentialNodeId() {
|
|
163
|
+
return this._lastNodeId;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param {TraceNode} node
|
|
168
|
+
*/
|
|
169
|
+
_setScopeNode(node) {
|
|
170
|
+
if (node.type === 'db') return;
|
|
171
|
+
|
|
172
|
+
const ctx = getCurrentContext();
|
|
173
|
+
if (ctx) {
|
|
174
|
+
ctx.scopeNodeId = node.id;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
_notifyChange() {
|
|
179
|
+
if (this.onChange && !this._finished) {
|
|
180
|
+
this.onChange();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @returns {TraceGraph}
|
|
186
|
+
*/
|
|
187
|
+
snapshot() {
|
|
188
|
+
return {
|
|
189
|
+
id: this.id,
|
|
190
|
+
timestamp: this.timestamp,
|
|
191
|
+
method: this.method,
|
|
192
|
+
url: this.url,
|
|
193
|
+
params: this.params,
|
|
194
|
+
body: this.body,
|
|
195
|
+
status: this.status,
|
|
196
|
+
state: 'active',
|
|
197
|
+
duration_ms: Math.round((performance.now() - this.startTime) * 10) / 10,
|
|
198
|
+
nodes: this.nodes,
|
|
199
|
+
edges: this.edges,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @param {string} from
|
|
205
|
+
* @param {string} to
|
|
206
|
+
*/
|
|
207
|
+
addEdge(from, to) {
|
|
208
|
+
this.edges.push({ from, to });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @param {number} status
|
|
213
|
+
* @param {unknown} [response]
|
|
214
|
+
* @returns {TraceGraph}
|
|
215
|
+
*/
|
|
216
|
+
finish(status, response) {
|
|
217
|
+
this._finished = true;
|
|
218
|
+
this.status = status;
|
|
219
|
+
const duration_ms = Math.round((performance.now() - this.startTime) * 10) / 10;
|
|
220
|
+
|
|
221
|
+
const responseNode = this._createNode({
|
|
222
|
+
type: 'response',
|
|
223
|
+
label: `${status}`,
|
|
224
|
+
duration_ms: null,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
this.nodes.push(responseNode);
|
|
228
|
+
|
|
229
|
+
if (this._lastNodeId) {
|
|
230
|
+
this.edges.push({ from: this._lastNodeId, to: responseNode.id });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
id: this.id,
|
|
235
|
+
timestamp: this.timestamp,
|
|
236
|
+
method: this.method,
|
|
237
|
+
url: this.url,
|
|
238
|
+
params: this.params,
|
|
239
|
+
body: this.body,
|
|
240
|
+
response,
|
|
241
|
+
status: this.status,
|
|
242
|
+
state: 'complete',
|
|
243
|
+
duration_ms,
|
|
244
|
+
nodes: this.nodes,
|
|
245
|
+
edges: this.edges,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = {
|
|
251
|
+
TraceTracer,
|
|
252
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {string} sql
|
|
5
|
+
*/
|
|
6
|
+
function extractTableFromSql(sql) {
|
|
7
|
+
const text = String(sql || '').replace(/\s+/g, ' ').trim();
|
|
8
|
+
|
|
9
|
+
const patterns = [
|
|
10
|
+
/\bfrom\s+["'`]?([a-zA-Z_][\w$]*)/i,
|
|
11
|
+
/\binto\s+["'`]?([a-zA-Z_][\w$]*)/i,
|
|
12
|
+
/\bupdate\s+["'`]?([a-zA-Z_][\w$]*)/i,
|
|
13
|
+
/\bjoin\s+["'`]?([a-zA-Z_][\w$]*)/i,
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
for (const pattern of patterns) {
|
|
17
|
+
const match = text.match(pattern);
|
|
18
|
+
if (match) return match[1];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string | undefined} name
|
|
26
|
+
*/
|
|
27
|
+
function humanizeName(name) {
|
|
28
|
+
if (!name) return 'records';
|
|
29
|
+
|
|
30
|
+
return String(name)
|
|
31
|
+
.replace(/^["'`]|["'`]$/g, '')
|
|
32
|
+
.replace(/_/g, ' ')
|
|
33
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
34
|
+
.toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} meta
|
|
39
|
+
* @param {string} [meta.driver]
|
|
40
|
+
* @param {string} [meta.operation]
|
|
41
|
+
* @param {string} [meta.query]
|
|
42
|
+
*/
|
|
43
|
+
function inferDbAction(meta) {
|
|
44
|
+
const operation = String(meta.operation || '').toLowerCase();
|
|
45
|
+
const query = String(meta.query || '').trim();
|
|
46
|
+
const sql = query.toUpperCase();
|
|
47
|
+
|
|
48
|
+
if (meta.driver === 'prisma') {
|
|
49
|
+
if (/^find|aggregate|groupby/i.test(operation)) return 'select';
|
|
50
|
+
if (/^create/i.test(operation)) return 'insert';
|
|
51
|
+
if (/^update|upsert/i.test(operation)) return 'update';
|
|
52
|
+
if (/^delete/i.test(operation)) return 'delete';
|
|
53
|
+
if (operation === 'count') return 'count';
|
|
54
|
+
if (operation === 'queryRaw' || operation === 'executeRaw') return 'query';
|
|
55
|
+
return 'query';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (meta.driver === 'mongoose') {
|
|
59
|
+
if (/find|distinct|count/i.test(operation)) return operation.includes('count') ? 'count' : 'select';
|
|
60
|
+
if (/insert|save|create/i.test(operation)) return 'insert';
|
|
61
|
+
if (/update|replace/i.test(operation)) return 'update';
|
|
62
|
+
if (/delete|remove/i.test(operation)) return 'delete';
|
|
63
|
+
return 'query';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (sql.startsWith('SELECT') || sql.startsWith('WITH')) return 'select';
|
|
67
|
+
if (sql.startsWith('INSERT')) return 'insert';
|
|
68
|
+
if (sql.startsWith('UPDATE')) return 'update';
|
|
69
|
+
if (sql.startsWith('DELETE')) return 'delete';
|
|
70
|
+
if (/\bCOUNT\s*\(/i.test(sql)) return 'count';
|
|
71
|
+
if (/^(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i.test(sql)) return 'transaction';
|
|
72
|
+
|
|
73
|
+
return 'query';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {'select' | 'insert' | 'update' | 'delete' | 'count' | 'transaction' | 'query'} action
|
|
78
|
+
* @param {string | undefined} table
|
|
79
|
+
*/
|
|
80
|
+
function describeDbIntent(action, table) {
|
|
81
|
+
const target = humanizeName(table);
|
|
82
|
+
|
|
83
|
+
switch (action) {
|
|
84
|
+
case 'select':
|
|
85
|
+
return `Fetching ${target}`;
|
|
86
|
+
case 'insert':
|
|
87
|
+
return `Saving to ${target}`;
|
|
88
|
+
case 'update':
|
|
89
|
+
return `Updating ${target}`;
|
|
90
|
+
case 'delete':
|
|
91
|
+
return `Removing from ${target}`;
|
|
92
|
+
case 'count':
|
|
93
|
+
return `Counting ${target}`;
|
|
94
|
+
case 'transaction':
|
|
95
|
+
return 'Running a database transaction';
|
|
96
|
+
default:
|
|
97
|
+
return table ? `Querying ${target}` : 'Running a database query';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {'select' | 'insert' | 'update' | 'delete' | 'count' | 'transaction' | 'query'} action
|
|
103
|
+
* @param {number | undefined} rows
|
|
104
|
+
* @param {string | undefined} error
|
|
105
|
+
*/
|
|
106
|
+
function describeDbResult(action, rows, error) {
|
|
107
|
+
if (error) {
|
|
108
|
+
const short = error.length > 72 ? `${error.slice(0, 72)}…` : error;
|
|
109
|
+
return `Failed — ${short}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
switch (action) {
|
|
113
|
+
case 'select':
|
|
114
|
+
if (rows === 0) return 'Nothing found';
|
|
115
|
+
if (rows === 1) return 'Returned 1 record';
|
|
116
|
+
if (rows != null) return `Returned ${rows} records`;
|
|
117
|
+
return 'Lookup finished';
|
|
118
|
+
case 'insert':
|
|
119
|
+
if (rows === 1) return 'Submitted 1 new row';
|
|
120
|
+
if (rows != null && rows > 1) return `Submitted ${rows} new rows`;
|
|
121
|
+
return 'Save completed';
|
|
122
|
+
case 'update':
|
|
123
|
+
if (rows === 0) return 'No rows changed';
|
|
124
|
+
if (rows === 1) return 'Updated 1 row';
|
|
125
|
+
if (rows != null) return `Updated ${rows} rows`;
|
|
126
|
+
return 'Update completed';
|
|
127
|
+
case 'delete':
|
|
128
|
+
if (rows === 0) return 'Nothing removed';
|
|
129
|
+
if (rows === 1) return 'Removed 1 row';
|
|
130
|
+
if (rows != null) return `Removed ${rows} rows`;
|
|
131
|
+
return 'Delete completed';
|
|
132
|
+
case 'count':
|
|
133
|
+
if (rows != null) return `Count is ${rows}`;
|
|
134
|
+
return 'Count completed';
|
|
135
|
+
case 'transaction':
|
|
136
|
+
return 'Transaction step completed';
|
|
137
|
+
default:
|
|
138
|
+
if (rows === 0) return 'No rows affected';
|
|
139
|
+
if (rows === 1) return '1 row affected';
|
|
140
|
+
if (rows != null) return `${rows} rows affected`;
|
|
141
|
+
return 'Query completed';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {'select' | 'insert' | 'update' | 'delete' | 'count' | 'transaction' | 'query'} action
|
|
147
|
+
* @param {string | undefined} table
|
|
148
|
+
*/
|
|
149
|
+
function describeDbLabel(action, table) {
|
|
150
|
+
const target = humanizeName(table);
|
|
151
|
+
|
|
152
|
+
switch (action) {
|
|
153
|
+
case 'select':
|
|
154
|
+
return `Fetch ${target}`;
|
|
155
|
+
case 'insert':
|
|
156
|
+
return `Save ${target}`;
|
|
157
|
+
case 'update':
|
|
158
|
+
return `Update ${target}`;
|
|
159
|
+
case 'delete':
|
|
160
|
+
return `Delete ${target}`;
|
|
161
|
+
case 'count':
|
|
162
|
+
return `Count ${target}`;
|
|
163
|
+
case 'transaction':
|
|
164
|
+
return 'Transaction';
|
|
165
|
+
default:
|
|
166
|
+
return table ? `Query ${target}` : 'Database query';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {object} meta
|
|
172
|
+
* @param {string} [meta.driver]
|
|
173
|
+
* @param {string} [meta.operation]
|
|
174
|
+
* @param {string} [meta.query]
|
|
175
|
+
* @param {string} [meta.collection]
|
|
176
|
+
* @param {number} [meta.rows]
|
|
177
|
+
* @param {number} [meta.duration_ms]
|
|
178
|
+
* @param {string} [meta.error]
|
|
179
|
+
*/
|
|
180
|
+
function buildDbNodeLanguage(meta) {
|
|
181
|
+
const action = inferDbAction(meta);
|
|
182
|
+
const table = meta.collection || extractTableFromSql(meta.query);
|
|
183
|
+
const intent = describeDbIntent(action, table);
|
|
184
|
+
const result = describeDbResult(action, meta.rows, meta.error);
|
|
185
|
+
const label = describeDbLabel(action, table);
|
|
186
|
+
const summary = meta.error
|
|
187
|
+
? result
|
|
188
|
+
: `${result}${meta.duration_ms != null ? ` · ${meta.duration_ms}ms` : ''}`;
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
label,
|
|
192
|
+
summary,
|
|
193
|
+
db_action: action,
|
|
194
|
+
db_intent: intent,
|
|
195
|
+
db_result: result,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = {
|
|
200
|
+
extractTableFromSql,
|
|
201
|
+
humanizeName,
|
|
202
|
+
inferDbAction,
|
|
203
|
+
describeDbIntent,
|
|
204
|
+
describeDbResult,
|
|
205
|
+
describeDbLabel,
|
|
206
|
+
buildDbNodeLanguage,
|
|
207
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createRequire } = require('module');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} packageName
|
|
9
|
+
*/
|
|
10
|
+
function isPackageInstalled(packageName) {
|
|
11
|
+
const searchPaths = [
|
|
12
|
+
process.cwd(),
|
|
13
|
+
path.join(process.cwd(), 'node_modules'),
|
|
14
|
+
...(require.main?.paths || []),
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const hostRequire = createRequire(path.join(process.cwd(), 'package.json'));
|
|
18
|
+
|
|
19
|
+
for (const base of searchPaths) {
|
|
20
|
+
try {
|
|
21
|
+
hostRequire.resolve(packageName, { paths: [base] });
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
// try next
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const localNodeModules = path.join(process.cwd(), 'node_modules', packageName);
|
|
29
|
+
return fs.existsSync(localNodeModules);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {Partial<Record<'mongoose' | 'pg' | 'prisma' | 'mysql2' | 'drizzle', boolean>>} [config]
|
|
34
|
+
*/
|
|
35
|
+
function resolveDriverConfig(config = {}) {
|
|
36
|
+
const detected = {
|
|
37
|
+
mongoose: isPackageInstalled('mongoose'),
|
|
38
|
+
pg: isPackageInstalled('pg'),
|
|
39
|
+
prisma: isPackageInstalled('@prisma/client'),
|
|
40
|
+
mysql2: isPackageInstalled('mysql2'),
|
|
41
|
+
drizzle: isPackageInstalled('drizzle-orm'),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const drizzle = config.drizzle !== false && detected.drizzle;
|
|
45
|
+
|
|
46
|
+
// Drizzle uses pg/mysql2 underneath — patch at the ORM layer to avoid duplicate nodes.
|
|
47
|
+
const pgExplicit = config.pg === true;
|
|
48
|
+
const mysql2Explicit = config.mysql2 === true;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
mongoose: config.mongoose !== false && detected.mongoose,
|
|
52
|
+
drizzle,
|
|
53
|
+
pg: config.pg !== false && detected.pg && (!drizzle || pgExplicit),
|
|
54
|
+
prisma: config.prisma !== false && detected.prisma,
|
|
55
|
+
mysql2: config.mysql2 !== false && detected.mysql2 && (!drizzle || mysql2Explicit),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
isPackageInstalled,
|
|
61
|
+
resolveDriverConfig,
|
|
62
|
+
};
|