thatcher 1.0.4 → 1.0.6

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.
Files changed (62) hide show
  1. package/package.json +18 -1
  2. package/src/app/api/debug/[[...path]]/route.js +217 -0
  3. package/src/app/api/formula/[[...path]]/route.js +142 -0
  4. package/src/cli.js +0 -1
  5. package/src/config/spec-helpers.js +6 -0
  6. package/src/engine.server.js +2 -2
  7. package/src/index.js +21 -11
  8. package/src/lib/auth-middleware.js +4 -2
  9. package/src/lib/busybase-adapter.js +162 -0
  10. package/src/lib/config-field-helpers.js +1 -1
  11. package/src/lib/config-generator-engine.js +24 -0
  12. package/src/lib/crud-handlers.js +21 -1
  13. package/src/lib/database-core.js +4 -7
  14. package/src/lib/database-migrations.js +254 -0
  15. package/src/lib/date-utils.js +164 -0
  16. package/src/lib/debug-registry.js +165 -0
  17. package/src/lib/error-handler.js +78 -0
  18. package/src/lib/export-sink.js +226 -0
  19. package/src/lib/field-iterator.js +126 -0
  20. package/src/lib/hyperformula-service.js +304 -0
  21. package/src/lib/metrics-collector.js +196 -0
  22. package/src/lib/observability-bootstrap.js +121 -0
  23. package/src/lib/perf-profiler.js +238 -0
  24. package/src/lib/query-engine.js +47 -0
  25. package/src/lib/query-string-adapter.js +97 -2
  26. package/src/lib/request-tracing.js +216 -0
  27. package/src/lib/response-formatter.js +0 -2
  28. package/src/lib/route-resolver.js +10 -0
  29. package/src/lib/status-helpers.js +128 -0
  30. package/src/lib/tracing.js +287 -0
  31. package/src/lib/utils.js +81 -0
  32. package/src/lib/validate.js +124 -1
  33. package/src/lib/xstate-workflow-engine.js +478 -0
  34. package/src/plugins/index.js +27 -0
  35. package/src/server/server.js +23 -4
  36. package/src/services/permission.service.js +6 -2
  37. package/src/ui/advanced-search-renderer.js +3 -3
  38. package/src/ui/advanced-widgets.js +4 -4
  39. package/src/ui/common-handlers.js +4 -2
  40. package/src/ui/dashboard-renderer.js +4 -4
  41. package/src/ui/engagement-cards.js +10 -10
  42. package/src/ui/engagement-grid-renderer.js +11 -8
  43. package/src/ui/entity-renderer.js +4 -2
  44. package/src/ui/event-delegation.js +348 -8
  45. package/src/ui/file-dialogs.js +9 -9
  46. package/src/ui/format-helpers.js +37 -3
  47. package/src/ui/highlight-threading-renderer.js +9 -9
  48. package/src/ui/job-management-renderer.js +6 -6
  49. package/src/ui/monitoring-dashboard-client.js +17 -3
  50. package/src/ui/notifications-renderer.js +6 -7
  51. package/src/ui/page-handler.js +9 -0
  52. package/src/ui/render-helpers.js +8 -1
  53. package/src/ui/review-comparison-renderer.js +1 -1
  54. package/src/ui/review-detail-renderer.js +6 -6
  55. package/src/ui/review-mwr-renderer.js +13 -10
  56. package/src/ui/review-widgets.js +5 -5
  57. package/src/ui/rfi-detail-renderer.js +9 -9
  58. package/src/ui/settings-renderer-advanced.js +13 -13
  59. package/src/ui/settings-renderer-teams.js +10 -9
  60. package/src/ui/settings-renderer.js +25 -24
  61. package/src/ui/spacing-system.js +8 -4
  62. package/src/ui/widgets.js +2 -2
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Performance Profiler - Threshold-based profiling with regression detection
3
+ * Per-operation p50/p95/p99 statistics with automatic alerting
4
+ */
5
+
6
+ import { createLogger } from './logger.js';
7
+
8
+ const logger = createLogger('[PerfProfiler]');
9
+
10
+ const _profiles = new Map();
11
+ const _thresholds = new Map();
12
+ const _alerts = [];
13
+ const MAX_ALERTS = 100;
14
+ const BASELINE_WINDOW_MS = 3600000;
15
+
16
+ const DEFAULT_THRESHOLDS = {
17
+ 'http.request': 500,
18
+ 'db.query': 100,
19
+ 'db.write': 200,
20
+ 'hook.execute': 50,
21
+ 'workflow.transition': 100,
22
+ 'formula.evaluate': 50,
23
+ 'auth.login': 300,
24
+ 'render.page': 200,
25
+ };
26
+
27
+ export class PerfProfiler {
28
+ constructor() {
29
+ this._profiles = _profiles;
30
+ this._thresholds = new Map(Object.entries(DEFAULT_THRESHOLDS));
31
+ }
32
+
33
+ setThreshold(operation, thresholdMs) {
34
+ this._thresholds.set(operation, thresholdMs);
35
+ }
36
+
37
+ getThreshold(operation) {
38
+ return this._thresholds.get(operation) || 100;
39
+ }
40
+
41
+ record(operation, durationMs, metadata = {}) {
42
+ let profile = _profiles.get(operation);
43
+ if (!profile) {
44
+ profile = {
45
+ operation,
46
+ count: 0,
47
+ totalMs: 0,
48
+ minMs: Infinity,
49
+ maxMs: 0,
50
+ durations: [],
51
+ slowCount: 0,
52
+ lastRecorded: null,
53
+ metadata: {},
54
+ };
55
+ _profiles.set(operation, profile);
56
+ }
57
+
58
+ profile.count++;
59
+ profile.totalMs += durationMs;
60
+ profile.minMs = Math.min(profile.minMs, durationMs);
61
+ profile.maxMs = Math.max(profile.maxMs, durationMs);
62
+ profile.lastRecorded = Date.now();
63
+
64
+ profile.durations.push({ durationMs, timestamp: Date.now(), ...metadata });
65
+ while (profile.durations.length > 10000) {
66
+ profile.durations.shift();
67
+ }
68
+
69
+ const threshold = this.getThreshold(operation);
70
+ if (durationMs > threshold) {
71
+ profile.slowCount++;
72
+ this._maybeAlert(operation, durationMs, threshold, metadata);
73
+ }
74
+
75
+ return profile;
76
+ }
77
+
78
+ async measure(operation, fn, metadata = {}) {
79
+ const start = performance.now();
80
+ try {
81
+ const result = await fn();
82
+ const duration = performance.now() - start;
83
+ this.record(operation, duration, metadata);
84
+ return result;
85
+ } catch (error) {
86
+ const duration = performance.now() - start;
87
+ this.record(operation, duration, { ...metadata, error: error.message });
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ measureSync(operation, fn, metadata = {}) {
93
+ const start = performance.now();
94
+ try {
95
+ const result = fn();
96
+ const duration = performance.now() - start;
97
+ this.record(operation, duration, metadata);
98
+ return result;
99
+ } catch (error) {
100
+ const duration = performance.now() - start;
101
+ this.record(operation, duration, { ...metadata, error: error.message });
102
+ throw error;
103
+ }
104
+ }
105
+
106
+ getStats(operation) {
107
+ const profile = _profiles.get(operation);
108
+ if (!profile) return null;
109
+
110
+ const durations = profile.durations.map(d => d.durationMs).sort((a, b) => a - b);
111
+ const count = durations.length;
112
+
113
+ return {
114
+ operation: profile.operation,
115
+ count: profile.count,
116
+ totalMs: profile.totalMs,
117
+ avgMs: profile.count > 0 ? profile.totalMs / profile.count : 0,
118
+ minMs: profile.minMs === Infinity ? 0 : profile.minMs,
119
+ maxMs: profile.maxMs,
120
+ p50: this._percentile(durations, 50),
121
+ p95: this._percentile(durations, 95),
122
+ p99: this._percentile(durations, 99),
123
+ slowCount: profile.slowCount,
124
+ threshold: this.getThreshold(operation),
125
+ lastRecorded: profile.lastRecorded,
126
+ };
127
+ }
128
+
129
+ getAllStats() {
130
+ return Array.from(_profiles.keys()).map(key => this.getStats(key));
131
+ }
132
+
133
+ getSlowOperations() {
134
+ return this.getAllStats()
135
+ .filter(s => s.slowCount > 0)
136
+ .sort((a, b) => b.slowCount - a.slowCount);
137
+ }
138
+
139
+ detectRegressions(windowMs = 300000) {
140
+ const regressions = [];
141
+ const now = Date.now();
142
+
143
+ for (const [operation, profile] of _profiles.entries()) {
144
+ const recent = profile.durations.filter(d => now - d.timestamp < windowMs);
145
+ const older = profile.durations.filter(d => now - d.timestamp >= windowMs && now - d.timestamp < windowMs * 2);
146
+
147
+ if (recent.length < 10 || older.length < 10) continue;
148
+
149
+ const recentAvg = recent.reduce((sum, d) => sum + d.durationMs, 0) / recent.length;
150
+ const olderAvg = older.reduce((sum, d) => sum + d.durationMs, 0) / older.length;
151
+
152
+ if (olderAvg > 0 && recentAvg > olderAvg * 1.5) {
153
+ regressions.push({
154
+ operation,
155
+ recentAvg: Math.round(recentAvg * 100) / 100,
156
+ olderAvg: Math.round(olderAvg * 100) / 100,
157
+ regressionPercent: Math.round((recentAvg / olderAvg - 1) * 100),
158
+ sampleSize: recent.length,
159
+ });
160
+ }
161
+ }
162
+
163
+ return regressions;
164
+ }
165
+
166
+ getAlerts() {
167
+ return [..._alerts];
168
+ }
169
+
170
+ clearAlerts() {
171
+ _alerts.length = 0;
172
+ }
173
+
174
+ clear() {
175
+ _profiles.clear();
176
+ _alerts.length = 0;
177
+ }
178
+
179
+ _percentile(sorted, p) {
180
+ if (sorted.length === 0) return 0;
181
+ const index = Math.ceil((p / 100) * sorted.length) - 1;
182
+ return sorted[Math.max(0, index)];
183
+ }
184
+
185
+ _maybeAlert(operation, durationMs, thresholdMs, metadata) {
186
+ const alertKey = `${operation}:slow`;
187
+ const existing = _alerts.find(a => a.key === alertKey && Date.now() - a.timestamp < 60000);
188
+ if (existing) return;
189
+
190
+ const alert = {
191
+ key: alertKey,
192
+ operation,
193
+ type: 'slow_operation',
194
+ durationMs: Math.round(durationMs * 100) / 100,
195
+ thresholdMs,
196
+ severity: durationMs > thresholdMs * 5 ? 'critical' : durationMs > thresholdMs * 2 ? 'warning' : 'info',
197
+ timestamp: Date.now(),
198
+ metadata,
199
+ };
200
+
201
+ _alerts.push(alert);
202
+ while (_alerts.length > MAX_ALERTS) {
203
+ _alerts.shift();
204
+ }
205
+
206
+ logger.warn('Slow operation detected', alert);
207
+ }
208
+ }
209
+
210
+ export const perfProfiler = new PerfProfiler();
211
+
212
+ export function createPerfProfiler() {
213
+ return new PerfProfiler();
214
+ }
215
+
216
+ export function getPerfProfiler() {
217
+ return perfProfiler;
218
+ }
219
+
220
+ export async function measurePerf(operation, fn, metadata = {}) {
221
+ return perfProfiler.measure(operation, fn, metadata);
222
+ }
223
+
224
+ export function measurePerfSync(operation, fn, metadata = {}) {
225
+ return perfProfiler.measureSync(operation, fn, metadata);
226
+ }
227
+
228
+ export default PerfProfiler;
229
+
230
+ if (globalThis.__debug__) {
231
+ globalThis.__debug__.expose('perf', {
232
+ stats: (op) => op ? perfProfiler.getStats(op) : perfProfiler.getAllStats(),
233
+ slow: () => perfProfiler.getSlowOperations(),
234
+ regressions: () => perfProfiler.detectRegressions(),
235
+ alerts: () => perfProfiler.getAlerts(),
236
+ thresholds: () => Object.fromEntries(perfProfiler._thresholds),
237
+ }, 'Performance Profiler');
238
+ }
@@ -42,6 +42,22 @@ function execGet(sql, params = [], context = {}) {
42
42
  }
43
43
  }
44
44
 
45
+ /**
46
+ * Execute a write statement (INSERT/UPDATE/DELETE) with error handling
47
+ * @param {string} sql
48
+ * @param {Array} params
49
+ * @param {object} context
50
+ * @returns {object} better-sqlite3 RunResult
51
+ */
52
+ function execRun(sql, params = [], context = {}) {
53
+ try {
54
+ return db.prepare(sql).run(...params);
55
+ } catch (e) {
56
+ logger.error(`${context.operation || 'Run'} ${context.entity || ''}`, { sql, error: e.message });
57
+ throw new Error(`Database run failed: ${e.message}`);
58
+ }
59
+ }
60
+
45
61
  /**
46
62
  * Get table name for entity (users vs user)
47
63
  * @param {object} spec
@@ -345,6 +361,28 @@ export function getChildren(parentEntity, parentId, childDef) {
345
361
  return execQuery(sql, [parentId], { entity: childDef.entity, operation: 'GetChildren' });
346
362
  }
347
363
 
364
+ /**
365
+ * Batch-fetch children of a parent for several child definitions at once.
366
+ * Ported from moonlanding; uses thatcher's `list` + getChildren fk convention
367
+ * (`fk`, falling back to moon's `foreignKey`, then `${parentEntity}_id`).
368
+ * @param {string} parentEntity
369
+ * @param {string|number} parentId
370
+ * @param {object|Array} childSpecs - map of key->def, or array of entity names
371
+ * @returns {Promise<object>} map of key -> rows
372
+ */
373
+ export function batchGetChildren(parentEntity, parentId, childSpecs) {
374
+ const childEntries = Array.isArray(childSpecs)
375
+ ? childSpecs.map(s => [s, { entity: s }])
376
+ : Object.entries(childSpecs);
377
+ const queries = childEntries.map(async ([key, def]) => {
378
+ const entity = def.entity || def;
379
+ const foreignKey = def.fk || def.foreignKey || `${parentEntity}_id`;
380
+ const results = list(entity, { [foreignKey]: parentId });
381
+ return [key, results];
382
+ });
383
+ return Promise.all(queries).then(results => Object.fromEntries(results));
384
+ }
385
+
348
386
  /**
349
387
  * Get pagination config from system settings
350
388
  * @param {object} spec - Entity spec
@@ -397,3 +435,12 @@ export async function withTransaction(callback) {
397
435
  throw e;
398
436
  }
399
437
  }
438
+
439
+ // Re-export write operations so consumers can import all CRUD from a single
440
+ // module (ported from moonlanding). Several thatcher modules already import
441
+ // `create`/`update`/`remove` from './query-engine.js'; this wires them through
442
+ // to the existing write implementation without changing those callers.
443
+ export { create, update, remove } from './query-engine-write.js';
444
+
445
+ // Expose low-level executors for callers that need raw SQL access.
446
+ export { execQuery, execGet, execRun };
@@ -3,6 +3,10 @@
3
3
  * Adapted from moonlanding/src/lib/query-string-adapter.js
4
4
  */
5
5
 
6
+ // Thatcher uses a fixed default page size (no async config-engine lookup);
7
+ // keeps QueryAdapter helpers synchronous to match existing call sites.
8
+ const DEFAULT_PAGE_SIZE = 50;
9
+
6
10
  /**
7
11
  * Parse query string from URL
8
12
  * @param {object} request - Fetch Request or NextRequest-like
@@ -67,9 +71,100 @@ function coerceValue(value) {
67
71
  export function getDefault(key) {
68
72
  const defaults = {
69
73
  page: 1,
70
- pageSize: 50,
74
+ pageSize: DEFAULT_PAGE_SIZE,
71
75
  q: null,
72
76
  filters: {},
77
+ sortDir: 'asc',
78
+ limit: null,
79
+ offset: null,
73
80
  };
74
- return defaults[key];
81
+ return defaults[key] ?? null;
82
+ }
83
+
84
+ /**
85
+ * QueryAdapter - static helpers for parsing and building query strings.
86
+ * Ported from moonlanding; methods kept synchronous to match thatcher call sites
87
+ * (e.g. api.js destructures QueryAdapter.fromSearchParams(...) without await).
88
+ */
89
+ export class QueryAdapter {
90
+ /**
91
+ * Parse a Request-like object. Delegates to thatcher's parseQuery so the
92
+ * returned shape ({ q, page, pageSize, filters, sort }) stays consistent.
93
+ */
94
+ static parse(request) {
95
+ return parseQuery(request);
96
+ }
97
+
98
+ /**
99
+ * Extract non-reserved filter params from a URLSearchParams instance.
100
+ */
101
+ static extractFilters(searchParams) {
102
+ const filters = {};
103
+ const reserved = new Set(['q', 'page', 'pageSize', 'page_size', 'action', 'limit', 'offset', 'sort', 'sortBy', 'dir', 'direction', 'sortDir', 'domain']);
104
+ for (const [key, value] of searchParams) {
105
+ if (!reserved.has(key) && value) {
106
+ filters[key] = value;
107
+ }
108
+ }
109
+ return filters;
110
+ }
111
+
112
+ /**
113
+ * Build a URLSearchParams from a params object, dropping empty values.
114
+ */
115
+ static build(params = {}) {
116
+ const query = new URLSearchParams();
117
+ Object.entries(params)
118
+ .filter(([, v]) => v !== undefined && v !== null && v !== '')
119
+ .forEach(([k, v]) => query.append(k, v));
120
+ return query;
121
+ }
122
+
123
+ /**
124
+ * Build a full URL from a base and params object.
125
+ */
126
+ static buildUrl(baseUrl, params = {}) {
127
+ const queryString = QueryAdapter.build(params).toString();
128
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl;
129
+ }
130
+
131
+ /**
132
+ * Get a default value for a config key (synchronous; delegates to getDefault).
133
+ */
134
+ static getDefault(key) {
135
+ return getDefault(key);
136
+ }
137
+
138
+ /**
139
+ * Parse pagination params from a URLSearchParams instance OR a plain object.
140
+ * Synchronous so callers can destructure the result directly.
141
+ */
142
+ static fromSearchParams(searchParams, spec = null) {
143
+ const get = (key) => {
144
+ if (searchParams && typeof searchParams.get === 'function') {
145
+ return searchParams.get(key);
146
+ }
147
+ return searchParams ? searchParams[key] : undefined;
148
+ };
149
+ const pageSizeParam = get('pageSize') || get('limit') || String(spec?.list?.pageSize || DEFAULT_PAGE_SIZE);
150
+ return {
151
+ q: get('q') || null,
152
+ page: Math.max(1, parseInt(get('page') || '1', 10)),
153
+ pageSize: parseInt(pageSizeParam, 10),
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Serialize a params object to a query string.
159
+ */
160
+ static toQueryString(params = {}) {
161
+ return QueryAdapter.build(params).toString();
162
+ }
75
163
  }
164
+
165
+ // Named exports matching moonlanding's surface, so consumers importing
166
+ // `parse`, `build`, `buildUrl`, `fromSearchParams` resolve correctly.
167
+ export const parse = (request) => QueryAdapter.parse(request);
168
+ export const build = (params) => QueryAdapter.build(params);
169
+ export const buildUrl = (baseUrl, params) => QueryAdapter.buildUrl(baseUrl, params);
170
+ export const fromSearchParams = (searchParams, spec) => QueryAdapter.fromSearchParams(searchParams, spec);
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Request Tracing Middleware - Instruments all HTTP requests with distributed tracing
3
+ * Wraps handlers with automatic trace/span creation and context propagation
4
+ */
5
+
6
+ import { tracer } from './tracing.js';
7
+ import { perfProfiler } from './perf-profiler.js';
8
+ import { createLogger } from './logger.js';
9
+
10
+ const logger = createLogger('[RequestTracing]');
11
+
12
+ export function withTracing(handler) {
13
+ return async (req, ...rest) => {
14
+ const method = req.method || 'UNKNOWN';
15
+ const url = req.url || '/';
16
+ const path = url.split('?')[0];
17
+
18
+ const traceparent = req.headers?.['traceparent'] || req.headers?.['x-traceparent'];
19
+ const extracted = traceparent ? tracer.extractTraceparent(traceparent) : null;
20
+
21
+ const attributes = {
22
+ 'http.method': method,
23
+ 'http.url': url,
24
+ 'http.path': path,
25
+ 'http.user_agent': req.headers?.['user-agent'],
26
+ 'http.client_ip': req.headers?.['x-forwarded-for'] || req.socket?.remoteAddress,
27
+ };
28
+
29
+ if (extracted) {
30
+ attributes['traceparent'] = traceparent;
31
+ }
32
+
33
+ return tracer.withSpan(`${method} ${path}`, async (span) => {
34
+ span.setAttributes(attributes);
35
+
36
+ const start = performance.now();
37
+
38
+ try {
39
+ let response;
40
+ if (rest.length > 0) {
41
+ response = await handler(req, ...rest);
42
+ } else {
43
+ response = await handler(req);
44
+ }
45
+
46
+ const duration = performance.now() - start;
47
+ const statusCode = response?.status || response?.statusCode || 200;
48
+
49
+ span.setAttribute('http.status_code', statusCode);
50
+ span.setAttribute('http.duration_ms', Math.round(duration * 100) / 100);
51
+
52
+ perfProfiler.record('http.request', duration, { method, path, statusCode });
53
+
54
+ return response;
55
+ } catch (error) {
56
+ span.recordException(error);
57
+ throw error;
58
+ }
59
+ }, { attributes });
60
+ };
61
+ }
62
+
63
+ export function withDbTracing(db, queryName = 'db.query') {
64
+ const original = { prepare: db.prepare?.bind(db) };
65
+
66
+ if (db.prepare) {
67
+ db.prepare = function(sql) {
68
+ const stmt = original.prepare(sql);
69
+
70
+ const wrapped = {};
71
+ for (const method of ['run', 'get', 'all']) {
72
+ if (stmt[method]) {
73
+ wrapped[method] = async function(...args) {
74
+ return tracer.withSpan(`${queryName}:${method}`, async (span) => {
75
+ span.setAttributes({
76
+ 'db.statement': sql,
77
+ 'db.operation': method,
78
+ 'db.system': 'sqlite',
79
+ });
80
+
81
+ const start = performance.now();
82
+ try {
83
+ const result = stmt[method].call(stmt, ...args);
84
+ const duration = performance.now() - start;
85
+
86
+ span.setAttribute('db.duration_ms', Math.round(duration * 100) / 100);
87
+ perfProfiler.record(`db.${method}`, duration, { sql: sql.slice(0, 100) });
88
+
89
+ return result;
90
+ } catch (error) {
91
+ span.recordException(error);
92
+ throw error;
93
+ }
94
+ });
95
+ };
96
+ }
97
+ }
98
+
99
+ return { ...stmt, ...wrapped };
100
+ };
101
+ }
102
+
103
+ return db;
104
+ }
105
+
106
+ export function withHookTracing(hookEngine) {
107
+ const originalExecute = hookEngine.execute.bind(hookEngine);
108
+ const originalPipe = hookEngine.pipe.bind(hookEngine);
109
+
110
+ hookEngine.execute = async function(name, data, options) {
111
+ return tracer.withSpan(`hook:${name}`, async (span) => {
112
+ span.setAttributes({
113
+ 'hook.name': name,
114
+ 'hook.handler_count': hookEngine.listeners(name).length,
115
+ });
116
+
117
+ const start = performance.now();
118
+ try {
119
+ const result = await originalExecute(name, data, options);
120
+ const duration = performance.now() - start;
121
+
122
+ span.setAttribute('hook.duration_ms', Math.round(duration * 100) / 100);
123
+ perfProfiler.record('hook.execute', duration, { name });
124
+
125
+ return result;
126
+ } catch (error) {
127
+ span.recordException(error);
128
+ throw error;
129
+ }
130
+ });
131
+ };
132
+
133
+ hookEngine.pipe = async function(name, data) {
134
+ return tracer.withSpan(`hook:pipe:${name}`, async (span) => {
135
+ span.setAttributes({
136
+ 'hook.name': name,
137
+ 'hook.handler_count': hookEngine.listeners(name).length,
138
+ });
139
+
140
+ const start = performance.now();
141
+ try {
142
+ const result = await originalPipe(name, data);
143
+ const duration = performance.now() - start;
144
+
145
+ span.setAttribute('hook.duration_ms', Math.round(duration * 100) / 100);
146
+
147
+ return result;
148
+ } catch (error) {
149
+ span.recordException(error);
150
+ throw error;
151
+ }
152
+ });
153
+ };
154
+
155
+ return hookEngine;
156
+ }
157
+
158
+ export function addTraceHeaders(response, traceId) {
159
+ if (!traceId) return response;
160
+
161
+ if (response?.headers) {
162
+ response.headers['X-Trace-Id'] = traceId;
163
+ }
164
+
165
+ if (typeof response?.setHeader === 'function') {
166
+ response.setHeader('X-Trace-Id', traceId);
167
+ }
168
+
169
+ return response;
170
+ }
171
+
172
+ export function createTracingMiddleware() {
173
+ return (req, res, next) => {
174
+ const traceparent = req.headers?.['traceparent'];
175
+ const extracted = traceparent ? tracer.extractTraceparent(traceparent) : null;
176
+
177
+ const { span, context } = tracer.startSpan(`${req.method} ${req.url}`, {
178
+ attributes: {
179
+ 'http.method': req.method,
180
+ 'http.url': req.url,
181
+ 'http.user_agent': req.headers?.['user-agent'],
182
+ },
183
+ });
184
+
185
+ if (extracted) {
186
+ span.setAttribute('trace.parent', traceparent);
187
+ }
188
+
189
+ const originalEnd = res.end;
190
+ const originalWriteHead = res.writeHead;
191
+
192
+ res.writeHead = function(...args) {
193
+ span.setAttribute('http.status_code', args[0]);
194
+ return originalWriteHead.apply(this, args);
195
+ };
196
+
197
+ res.end = function(...args) {
198
+ const duration = performance.now() - span.startTime;
199
+ span.setAttribute('http.duration_ms', Math.round(duration * 100) / 100);
200
+ span.end();
201
+
202
+ tracer._exportSpan(span);
203
+
204
+ return originalEnd.apply(this, args);
205
+ };
206
+
207
+ req.traceId = context.traceId;
208
+ req.span = span;
209
+
210
+ if (next) {
211
+ return next();
212
+ }
213
+ };
214
+ }
215
+
216
+ export default withTracing;
@@ -76,5 +76,3 @@ export function error(message, status = HTTP.INTERNAL_ERROR, code = 'ERROR') {
76
76
  export function withMetadata(data, status, type = 'success') {
77
77
  return { ...data, _meta: { status, type, timestamp: Date.now() } };
78
78
  }
79
-
80
- import { HTTP } from '../config/constants.js';
@@ -62,6 +62,16 @@ export function buildNestedRoutePath(baseDir, domain, parentEntity, childParts)
62
62
  () => buildSegments('[id]', () => `[${singularize(childParts[0])}Id]`),
63
63
  ];
64
64
 
65
+ // Also try a "leaf-action" shape with no further id after the child segment.
66
+ // Routes like /mwr/review/[id]/export-pdf/route.js (parent + id + leaf action)
67
+ // were missed by the segment-pair variants above.
68
+ if (childParts.length === 1) {
69
+ const leaf = path.join(baseDir, 'src/app/api', domain, parentEntity, '[id]', childParts[0], 'route.js');
70
+ if (fs.existsSync(leaf)) return leaf;
71
+ const leafByEntity = path.join(baseDir, 'src/app/api', domain, parentEntity, `[${parentEntity}Id]`, childParts[0], 'route.js');
72
+ if (fs.existsSync(leafByEntity)) return leafByEntity;
73
+ }
74
+
65
75
  for (const variant of variants) {
66
76
  const candidate = variant();
67
77
  if (fs.existsSync(candidate)) return candidate;