@actuate-media/cms-core 0.25.1 → 0.26.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.
Files changed (33) hide show
  1. package/dist/__tests__/api/stats-routes.test.d.ts +2 -0
  2. package/dist/__tests__/api/stats-routes.test.d.ts.map +1 -0
  3. package/dist/__tests__/api/stats-routes.test.js +251 -0
  4. package/dist/__tests__/api/stats-routes.test.js.map +1 -0
  5. package/dist/__tests__/content-health/scanner.test.d.ts +2 -0
  6. package/dist/__tests__/content-health/scanner.test.d.ts.map +1 -0
  7. package/dist/__tests__/content-health/scanner.test.js +151 -0
  8. package/dist/__tests__/content-health/scanner.test.js.map +1 -0
  9. package/dist/__tests__/diagnostics/api-metrics.test.d.ts +2 -0
  10. package/dist/__tests__/diagnostics/api-metrics.test.d.ts.map +1 -0
  11. package/dist/__tests__/diagnostics/api-metrics.test.js +153 -0
  12. package/dist/__tests__/diagnostics/api-metrics.test.js.map +1 -0
  13. package/dist/api/handler-factory.d.ts.map +1 -1
  14. package/dist/api/handler-factory.js +31 -1
  15. package/dist/api/handler-factory.js.map +1 -1
  16. package/dist/api/handlers.d.ts.map +1 -1
  17. package/dist/api/handlers.js +263 -0
  18. package/dist/api/handlers.js.map +1 -1
  19. package/dist/content-health/cron.d.ts +67 -0
  20. package/dist/content-health/cron.d.ts.map +1 -0
  21. package/dist/content-health/cron.js +167 -0
  22. package/dist/content-health/cron.js.map +1 -0
  23. package/dist/content-health/scanner.d.ts +118 -0
  24. package/dist/content-health/scanner.d.ts.map +1 -0
  25. package/dist/content-health/scanner.js +263 -0
  26. package/dist/content-health/scanner.js.map +1 -0
  27. package/dist/diagnostics/api-metrics.d.ts +135 -0
  28. package/dist/diagnostics/api-metrics.d.ts.map +1 -0
  29. package/dist/diagnostics/api-metrics.js +374 -0
  30. package/dist/diagnostics/api-metrics.js.map +1 -0
  31. package/package.json +1 -1
  32. package/prisma/migrations/0007_dashboard_metrics/migration.sql +74 -0
  33. package/prisma/schema.prisma +180 -73
@@ -0,0 +1,135 @@
1
+ /**
2
+ * API request instrumentation that powers the Dashboard "Delivery API"
3
+ * tiles (request volume / error rate / avg latency / p95).
4
+ *
5
+ * ## Why per-minute aggregated buckets and not per-request rows
6
+ *
7
+ * A single 24-hour window at 100 req/s on 20 routes is ~17M raw rows,
8
+ * but the same window aggregated to (minute × route × status-bucket)
9
+ * is ≤ 1440 × 20 × 5 = 144k rows — and in practice far less because
10
+ * most route/minute combinations have zero traffic. The reader
11
+ * (`/stats/api-delivery`) folds the last 1440 minute buckets into
12
+ * four scalars; the writer does one atomic UPSERT per request via
13
+ * raw SQL.
14
+ *
15
+ * ## Why p95 via histogram and not from raw samples
16
+ *
17
+ * Storing a histogram per bucket gives us percentile estimates with
18
+ * ~10% error vs ground truth while keeping the table tiny. The
19
+ * `LATENCY_BUCKETS` boundary list is log-scale (powers of 2 in
20
+ * milliseconds) so it covers ~1 ms heartbeats through multi-second
21
+ * outliers in 12 columns. p95 falls out as a `findIndex` once the
22
+ * histogram is merged across all minute buckets in the window.
23
+ *
24
+ * ## Why fire-and-forget and not awaited
25
+ *
26
+ * Metric recording MUST NOT slow the user-facing response. The
27
+ * `record()` call dispatches the UPSERT to a `void`-discarded promise
28
+ * so a slow DB round-trip never blocks the request that produced
29
+ * the metric. Errors are swallowed at the source — losing a metric
30
+ * sample is strictly preferable to surfacing a metric-pipeline error
31
+ * to the user.
32
+ *
33
+ * ## Why we self-exempt the dashboard polling routes
34
+ *
35
+ * The dashboard refetches `/stats` and `/stats/api-delivery` on a
36
+ * timer. If we counted those requests they'd dominate the volume
37
+ * tile and inflate the error rate (a logged-out tab would hit
38
+ * `/stats` and get 401 repeatedly). The route-normaliser excludes
39
+ * them explicitly — see `shouldRecord`.
40
+ */
41
+ /**
42
+ * Upper bound (exclusive) of each histogram bucket, in milliseconds.
43
+ * The last bucket is `>= 1024 ms` (no upper bound).
44
+ *
45
+ * Indexed:
46
+ * 0: <1ms 1: 1-2ms 2: 2-4ms 3: 4-8ms
47
+ * 4: 8-16ms 5: 16-32ms 6: 32-64ms 7: 64-128ms
48
+ * 8: 128-256 9: 256-512 10: 512-1024 11: >=1024
49
+ */
50
+ export declare const LATENCY_BUCKETS: readonly [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024];
51
+ export declare const HISTOGRAM_LENGTH: number;
52
+ export type StatusBucket = '2xx' | '3xx' | '4xx' | '5xx' | 'other';
53
+ export interface ApiMetricSample {
54
+ /** Request path, CMS-relative — e.g. `/collections/posts/abc123`. */
55
+ pathname: string;
56
+ status: number;
57
+ /** Response time in milliseconds. May be a float. */
58
+ durationMs: number;
59
+ }
60
+ /**
61
+ * Bucket a status code into one of 5 families. We keep the 1xx family
62
+ * out of the public type because no CMS route ever responds with one;
63
+ * if it ever happens, the `other` bucket catches it instead of
64
+ * silently being lost.
65
+ */
66
+ export declare function statusBucketOf(status: number): StatusBucket;
67
+ /**
68
+ * Return the index (0..HISTOGRAM_LENGTH-1) of the histogram bucket
69
+ * that this latency falls into. NaN / negative values fall in bucket
70
+ * 0 (treated as "<1ms") so a metric write never crashes on a bad
71
+ * input.
72
+ */
73
+ export declare function histogramBucketIndex(durationMs: number): number;
74
+ /**
75
+ * Build a fresh histogram array with a single sample placed in the
76
+ * correct bucket. Used to produce the JSON payload for an INSERT.
77
+ */
78
+ export declare function makeHistogramFor(durationMs: number): number[];
79
+ /**
80
+ * Element-wise add two histograms. Tolerant of either operand being
81
+ * missing or shorter than `HISTOGRAM_LENGTH` — defends against schema
82
+ * mismatches (e.g. an old row written before a bucket boundary change).
83
+ */
84
+ export declare function mergeHistograms(a: number[] | null | undefined, b: number[]): number[];
85
+ /**
86
+ * Snap a Date to the start of its UTC minute. Returns a new Date —
87
+ * never mutates the input.
88
+ */
89
+ export declare function bucketStartFor(now?: Date | number): Date;
90
+ /**
91
+ * Estimate percentile-N from a merged histogram. Returns the upper
92
+ * bound of the bucket that contains the requested percentile —
93
+ * which is the right "user-visible" answer (we are happy to say
94
+ * "p95 < 256ms" rather than guess the exact value inside the
95
+ * 128-256 bucket).
96
+ *
97
+ * For the overflow bucket (no upper bound), we report
98
+ * `LATENCY_BUCKETS[last]` as a lower-bound estimate; callers
99
+ * displaying this should annotate with a "≥" prefix if they want
100
+ * to expose the open-ended nature.
101
+ */
102
+ export declare function estimatePercentile(histogram: number[], percentile: number): number;
103
+ /**
104
+ * Normalise a request path to bound metric-row cardinality.
105
+ *
106
+ * /collections/posts/abc123 → /collections/posts/:id
107
+ * /globals/site-settings → /globals/:slug
108
+ * /collections/posts/abc123/restore→ /collections/posts/:id/restore
109
+ * /unknown/long/path/that/leaks → /other
110
+ *
111
+ * The "/other" bucket catches anything we don't explicitly know
112
+ * about so a single hostile client with random URLs can't blow up
113
+ * the table.
114
+ */
115
+ export declare function normalizeRoute(pathname: string): string;
116
+ /**
117
+ * Routes that the dashboard polls itself, plus webhooks the cron
118
+ * fires. Counting these would either (a) cause feedback loops
119
+ * inflating the volume tile or (b) report cron-only traffic the
120
+ * editor never produces. The instrumentation skips them.
121
+ */
122
+ export declare function shouldRecord(normalizedRoute: string): boolean;
123
+ /**
124
+ * Record one API request to the metrics table. **Fire-and-forget** —
125
+ * callers MUST NOT `await` this. Errors are swallowed; the next
126
+ * request retries, and 1-minute aggregates are robust to occasional
127
+ * write loss.
128
+ *
129
+ * Writes a single SQL UPSERT so two concurrent requests landing in
130
+ * the same bucket interleave cleanly via Postgres's MVCC + the
131
+ * unique-on-conflict path. The histogram column is merged
132
+ * element-wise inside SQL, so we never read-modify-write from JS.
133
+ */
134
+ export declare function recordApiRequest(sample: ApiMetricSample): void;
135
+ //# sourceMappingURL=api-metrics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-metrics.d.ts","sourceRoot":"","sources":["../../src/diagnostics/api-metrics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AASH;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,wDAAyD,CAAA;AAErF,eAAO,MAAM,gBAAgB,QAA6B,CAAA;AAE1D,MAAM,MAAM,YAAY,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,OAAO,CAAA;AAElE,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,qDAAqD;IACrD,UAAU,EAAE,MAAM,CAAA;CACnB;AAID;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,CAM3D;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAM/D;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CAI7D;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAMrF;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,GAAG,GAAE,IAAI,GAAG,MAAmB,GAAG,IAAI,CAGpE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAalF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAiBvD;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,eAAe,EAAE,MAAM,GAAG,OAAO,CAE7D;AAiGD;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI,CAY9D"}
@@ -0,0 +1,374 @@
1
+ /**
2
+ * API request instrumentation that powers the Dashboard "Delivery API"
3
+ * tiles (request volume / error rate / avg latency / p95).
4
+ *
5
+ * ## Why per-minute aggregated buckets and not per-request rows
6
+ *
7
+ * A single 24-hour window at 100 req/s on 20 routes is ~17M raw rows,
8
+ * but the same window aggregated to (minute × route × status-bucket)
9
+ * is ≤ 1440 × 20 × 5 = 144k rows — and in practice far less because
10
+ * most route/minute combinations have zero traffic. The reader
11
+ * (`/stats/api-delivery`) folds the last 1440 minute buckets into
12
+ * four scalars; the writer does one atomic UPSERT per request via
13
+ * raw SQL.
14
+ *
15
+ * ## Why p95 via histogram and not from raw samples
16
+ *
17
+ * Storing a histogram per bucket gives us percentile estimates with
18
+ * ~10% error vs ground truth while keeping the table tiny. The
19
+ * `LATENCY_BUCKETS` boundary list is log-scale (powers of 2 in
20
+ * milliseconds) so it covers ~1 ms heartbeats through multi-second
21
+ * outliers in 12 columns. p95 falls out as a `findIndex` once the
22
+ * histogram is merged across all minute buckets in the window.
23
+ *
24
+ * ## Why fire-and-forget and not awaited
25
+ *
26
+ * Metric recording MUST NOT slow the user-facing response. The
27
+ * `record()` call dispatches the UPSERT to a `void`-discarded promise
28
+ * so a slow DB round-trip never blocks the request that produced
29
+ * the metric. Errors are swallowed at the source — losing a metric
30
+ * sample is strictly preferable to surfacing a metric-pipeline error
31
+ * to the user.
32
+ *
33
+ * ## Why we self-exempt the dashboard polling routes
34
+ *
35
+ * The dashboard refetches `/stats` and `/stats/api-delivery` on a
36
+ * timer. If we counted those requests they'd dominate the volume
37
+ * tile and inflate the error rate (a logged-out tab would hit
38
+ * `/stats` and get 401 repeatedly). The route-normaliser excludes
39
+ * them explicitly — see `shouldRecord`.
40
+ */
41
+ import { createLogger } from './logger.js';
42
+ import { getDB } from '../db.js';
43
+ const logger = createLogger('api-metrics');
44
+ // ─── Public constants ──────────────────────────────────────────────────────
45
+ /**
46
+ * Upper bound (exclusive) of each histogram bucket, in milliseconds.
47
+ * The last bucket is `>= 1024 ms` (no upper bound).
48
+ *
49
+ * Indexed:
50
+ * 0: <1ms 1: 1-2ms 2: 2-4ms 3: 4-8ms
51
+ * 4: 8-16ms 5: 16-32ms 6: 32-64ms 7: 64-128ms
52
+ * 8: 128-256 9: 256-512 10: 512-1024 11: >=1024
53
+ */
54
+ export const LATENCY_BUCKETS = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024];
55
+ export const HISTOGRAM_LENGTH = LATENCY_BUCKETS.length + 1; // 12
56
+ // ─── Pure helpers ──────────────────────────────────────────────────────────
57
+ /**
58
+ * Bucket a status code into one of 5 families. We keep the 1xx family
59
+ * out of the public type because no CMS route ever responds with one;
60
+ * if it ever happens, the `other` bucket catches it instead of
61
+ * silently being lost.
62
+ */
63
+ export function statusBucketOf(status) {
64
+ if (status >= 200 && status < 300)
65
+ return '2xx';
66
+ if (status >= 300 && status < 400)
67
+ return '3xx';
68
+ if (status >= 400 && status < 500)
69
+ return '4xx';
70
+ if (status >= 500 && status < 600)
71
+ return '5xx';
72
+ return 'other';
73
+ }
74
+ /**
75
+ * Return the index (0..HISTOGRAM_LENGTH-1) of the histogram bucket
76
+ * that this latency falls into. NaN / negative values fall in bucket
77
+ * 0 (treated as "<1ms") so a metric write never crashes on a bad
78
+ * input.
79
+ */
80
+ export function histogramBucketIndex(durationMs) {
81
+ if (!Number.isFinite(durationMs) || durationMs < 1)
82
+ return 0;
83
+ for (let i = 0; i < LATENCY_BUCKETS.length; i++) {
84
+ if (durationMs < LATENCY_BUCKETS[i])
85
+ return i;
86
+ }
87
+ return LATENCY_BUCKETS.length; // overflow bucket
88
+ }
89
+ /**
90
+ * Build a fresh histogram array with a single sample placed in the
91
+ * correct bucket. Used to produce the JSON payload for an INSERT.
92
+ */
93
+ export function makeHistogramFor(durationMs) {
94
+ const arr = new Array(HISTOGRAM_LENGTH).fill(0);
95
+ arr[histogramBucketIndex(durationMs)] = 1;
96
+ return arr;
97
+ }
98
+ /**
99
+ * Element-wise add two histograms. Tolerant of either operand being
100
+ * missing or shorter than `HISTOGRAM_LENGTH` — defends against schema
101
+ * mismatches (e.g. an old row written before a bucket boundary change).
102
+ */
103
+ export function mergeHistograms(a, b) {
104
+ const out = new Array(HISTOGRAM_LENGTH).fill(0);
105
+ for (let i = 0; i < HISTOGRAM_LENGTH; i++) {
106
+ out[i] = (a?.[i] ?? 0) + (b[i] ?? 0);
107
+ }
108
+ return out;
109
+ }
110
+ /**
111
+ * Snap a Date to the start of its UTC minute. Returns a new Date —
112
+ * never mutates the input.
113
+ */
114
+ export function bucketStartFor(now = new Date()) {
115
+ const ms = typeof now === 'number' ? now : now.getTime();
116
+ return new Date(ms - (ms % 60_000));
117
+ }
118
+ /**
119
+ * Estimate percentile-N from a merged histogram. Returns the upper
120
+ * bound of the bucket that contains the requested percentile —
121
+ * which is the right "user-visible" answer (we are happy to say
122
+ * "p95 < 256ms" rather than guess the exact value inside the
123
+ * 128-256 bucket).
124
+ *
125
+ * For the overflow bucket (no upper bound), we report
126
+ * `LATENCY_BUCKETS[last]` as a lower-bound estimate; callers
127
+ * displaying this should annotate with a "≥" prefix if they want
128
+ * to expose the open-ended nature.
129
+ */
130
+ export function estimatePercentile(histogram, percentile) {
131
+ const total = histogram.reduce((s, v) => s + v, 0);
132
+ if (total === 0)
133
+ return 0;
134
+ const target = total * percentile;
135
+ let acc = 0;
136
+ for (let i = 0; i < HISTOGRAM_LENGTH; i++) {
137
+ acc += histogram[i] ?? 0;
138
+ if (acc >= target) {
139
+ if (i < LATENCY_BUCKETS.length)
140
+ return LATENCY_BUCKETS[i];
141
+ return LATENCY_BUCKETS[LATENCY_BUCKETS.length - 1] * 2; // overflow estimate
142
+ }
143
+ }
144
+ return 0;
145
+ }
146
+ /**
147
+ * Normalise a request path to bound metric-row cardinality.
148
+ *
149
+ * /collections/posts/abc123 → /collections/posts/:id
150
+ * /globals/site-settings → /globals/:slug
151
+ * /collections/posts/abc123/restore→ /collections/posts/:id/restore
152
+ * /unknown/long/path/that/leaks → /other
153
+ *
154
+ * The "/other" bucket catches anything we don't explicitly know
155
+ * about so a single hostile client with random URLs can't blow up
156
+ * the table.
157
+ */
158
+ export function normalizeRoute(pathname) {
159
+ // Strip query string + trailing slash (except root).
160
+ let p = pathname.split('?')[0] ?? '/';
161
+ if (p.length > 1 && p.endsWith('/'))
162
+ p = p.slice(0, -1);
163
+ // Strip the `/api/cms` prefix if still present — depending on where
164
+ // we instrument, the path may or may not have been rewritten yet.
165
+ if (p.startsWith('/api/cms'))
166
+ p = p.slice('/api/cms'.length) || '/';
167
+ // Static or top-level routes — pass through verbatim.
168
+ if (KNOWN_STATIC_ROUTES.has(p))
169
+ return p;
170
+ // Pattern matchers for cardinality-bounding.
171
+ for (const { pattern, replacement } of ROUTE_PATTERNS) {
172
+ if (pattern.test(p))
173
+ return p.replace(pattern, replacement);
174
+ }
175
+ return '/other';
176
+ }
177
+ /**
178
+ * Routes that the dashboard polls itself, plus webhooks the cron
179
+ * fires. Counting these would either (a) cause feedback loops
180
+ * inflating the volume tile or (b) report cron-only traffic the
181
+ * editor never produces. The instrumentation skips them.
182
+ */
183
+ export function shouldRecord(normalizedRoute) {
184
+ return !SKIP_ROUTES.has(normalizedRoute);
185
+ }
186
+ // ─── Lookup tables (kept module-scope so they're built once) ───────────────
187
+ /**
188
+ * Verbatim routes — the dashboard, auth, health, etc. Keep this
189
+ * small; if your CMS exposes many top-level routes, prefer
190
+ * `ROUTE_PATTERNS` so the table stays bounded.
191
+ */
192
+ const KNOWN_STATIC_ROUTES = new Set([
193
+ '/',
194
+ '/health',
195
+ '/stats',
196
+ '/stats/api-delivery',
197
+ '/stats/content-health',
198
+ '/auth/login',
199
+ '/auth/logout',
200
+ '/auth/me',
201
+ '/auth/csrf',
202
+ '/auth/forgot-password',
203
+ '/auth/reset-password',
204
+ '/auth/totp/login',
205
+ '/setup/status',
206
+ '/setup/create-admin',
207
+ '/search',
208
+ '/users',
209
+ '/api-keys',
210
+ '/notifications',
211
+ '/notifications/stream',
212
+ '/notifications/read-all',
213
+ '/media',
214
+ '/media/upload',
215
+ '/forms',
216
+ '/seo/config',
217
+ '/seo/analyze',
218
+ '/redirects',
219
+ '/script-tags',
220
+ '/saved-sections',
221
+ '/page-templates',
222
+ '/webhooks',
223
+ '/webhooks/test',
224
+ ]);
225
+ /**
226
+ * Skip these from the metric — they're either the dashboard polling
227
+ * itself (feedback loop) or cron-only traffic that distorts the
228
+ * editor-facing tiles.
229
+ */
230
+ const SKIP_ROUTES = new Set([
231
+ '/stats',
232
+ '/stats/api-delivery',
233
+ '/stats/content-health',
234
+ '/health',
235
+ '/notifications/stream', // long-lived SSE; counting once on open is misleading
236
+ ]);
237
+ const ROUTE_PATTERNS = [
238
+ {
239
+ pattern: /^\/collections\/[^/]+\/[^/]+\/restore$/,
240
+ replacement: '/collections/:slug/:id/restore',
241
+ },
242
+ {
243
+ pattern: /^\/collections\/[^/]+\/[^/]+\/versions(?:\/[^/]+)?$/,
244
+ replacement: '/collections/:slug/:id/versions',
245
+ },
246
+ {
247
+ pattern: /^\/collections\/[^/]+\/[^/]+\/duplicate$/,
248
+ replacement: '/collections/:slug/:id/duplicate',
249
+ },
250
+ { pattern: /^\/collections\/[^/]+\/[^/]+$/, replacement: '/collections/:slug/:id' },
251
+ { pattern: /^\/collections\/[^/]+$/, replacement: '/collections/:slug' },
252
+ { pattern: /^\/globals\/[^/]+$/, replacement: '/globals/:slug' },
253
+ { pattern: /^\/media\/[^/]+$/, replacement: '/media/:id' },
254
+ { pattern: /^\/forms\/[^/]+\/submit$/, replacement: '/forms/:id/submit' },
255
+ { pattern: /^\/forms\/[^/]+\/submissions(?:\/[^/]+)?$/, replacement: '/forms/:id/submissions' },
256
+ { pattern: /^\/forms\/[^/]+$/, replacement: '/forms/:id' },
257
+ { pattern: /^\/users\/[^/]+$/, replacement: '/users/:id' },
258
+ { pattern: /^\/api-keys\/[^/]+$/, replacement: '/api-keys/:id' },
259
+ { pattern: /^\/redirects\/[^/]+$/, replacement: '/redirects/:id' },
260
+ { pattern: /^\/script-tags\/[^/]+$/, replacement: '/script-tags/:id' },
261
+ { pattern: /^\/saved-sections\/[^/]+$/, replacement: '/saved-sections/:id' },
262
+ { pattern: /^\/page-templates\/[^/]+$/, replacement: '/page-templates/:id' },
263
+ { pattern: /^\/webhooks\/[^/]+(?:\/.+)?$/, replacement: '/webhooks/:id' },
264
+ { pattern: /^\/notifications\/[^/]+\/read$/, replacement: '/notifications/:id/read' },
265
+ { pattern: /^\/cron\/.+/, replacement: '/cron/:job' },
266
+ { pattern: /^\/dev\/.+/, replacement: '/dev/:fn' },
267
+ { pattern: /^\/seo\/.+/, replacement: '/seo/:fn' },
268
+ { pattern: /^\/share\/.+/, replacement: '/share/:token' },
269
+ { pattern: /^\/preview\/.+/, replacement: '/preview/:fn' },
270
+ { pattern: /^\/page-builder\/.+/, replacement: '/page-builder/:fn' },
271
+ { pattern: /^\/ai\/.+/, replacement: '/ai/:fn' },
272
+ { pattern: /^\/auth\/.+/, replacement: '/auth/:fn' },
273
+ { pattern: /^\/resolve(?:\/.+)?$/, replacement: '/resolve' },
274
+ ];
275
+ // ─── Writer ────────────────────────────────────────────────────────────────
276
+ /**
277
+ * Record one API request to the metrics table. **Fire-and-forget** —
278
+ * callers MUST NOT `await` this. Errors are swallowed; the next
279
+ * request retries, and 1-minute aggregates are robust to occasional
280
+ * write loss.
281
+ *
282
+ * Writes a single SQL UPSERT so two concurrent requests landing in
283
+ * the same bucket interleave cleanly via Postgres's MVCC + the
284
+ * unique-on-conflict path. The histogram column is merged
285
+ * element-wise inside SQL, so we never read-modify-write from JS.
286
+ */
287
+ export function recordApiRequest(sample) {
288
+ const route = normalizeRoute(sample.pathname);
289
+ if (!shouldRecord(route))
290
+ return;
291
+ // Schedule the write but do not await. Returning `void` is the
292
+ // contract — callers shouldn't be tempted to `.then()` on it.
293
+ void writeMetricBucket({
294
+ bucketStart: bucketStartFor(),
295
+ route,
296
+ statusBucket: statusBucketOf(sample.status),
297
+ durationMs: sample.durationMs,
298
+ });
299
+ }
300
+ async function writeMetricBucket(args) {
301
+ let db;
302
+ try {
303
+ db = getDB();
304
+ }
305
+ catch {
306
+ // DB not initialised yet (e.g. dev server hot-reload race) —
307
+ // skip silently. There's nothing useful to log here and we
308
+ // don't want a stack trace on every dropped sample.
309
+ return;
310
+ }
311
+ // Some hosts ship a Prisma client that doesn't include the
312
+ // `apiRequestMetric` model (e.g. third-party deploys that copied
313
+ // an older schema). Guard so we degrade silently rather than 500ing
314
+ // every request.
315
+ if (!db || typeof db.$executeRawUnsafe !== 'function' || !('apiRequestMetric' in db)) {
316
+ return;
317
+ }
318
+ const histogram = makeHistogramFor(args.durationMs);
319
+ try {
320
+ // Atomic INSERT...ON CONFLICT DO UPDATE. The histogram merge is
321
+ // expressed as a `jsonb_build_array` over the existing + new
322
+ // bucket counts so we never read the prior row from JS.
323
+ //
324
+ // CUID-ish ID generation via gen_random_uuid() keeps the ID
325
+ // schema-compatible with the model's `@default(cuid())` (both
326
+ // are 26+ char ASCII strings; Prisma's cuid is preferred but
327
+ // SQL-level UUIDs are accepted by the column type and not
328
+ // surfaced to consumers).
329
+ await db.$executeRawUnsafe(`
330
+ INSERT INTO "actuate_api_request_metrics"
331
+ ("id", "bucketStart", "route", "statusBucket", "count", "sumLatencyMs", "maxLatencyMs", "latencyHistogram", "updatedAt")
332
+ VALUES (
333
+ 'm_' || replace(gen_random_uuid()::text, '-', ''),
334
+ $1::timestamp,
335
+ $2,
336
+ $3,
337
+ 1,
338
+ $4,
339
+ $4,
340
+ $5::jsonb,
341
+ NOW()
342
+ )
343
+ ON CONFLICT ("bucketStart", "route", "statusBucket") DO UPDATE
344
+ SET "count" = "actuate_api_request_metrics"."count" + 1,
345
+ "sumLatencyMs" = "actuate_api_request_metrics"."sumLatencyMs" + EXCLUDED."sumLatencyMs",
346
+ "maxLatencyMs" = GREATEST("actuate_api_request_metrics"."maxLatencyMs", EXCLUDED."maxLatencyMs"),
347
+ "latencyHistogram" = (
348
+ SELECT jsonb_agg(
349
+ COALESCE(("actuate_api_request_metrics"."latencyHistogram"->>i)::int, 0) +
350
+ COALESCE((EXCLUDED."latencyHistogram"->>i)::int, 0)
351
+ ORDER BY i
352
+ )
353
+ FROM generate_series(0, ${HISTOGRAM_LENGTH - 1}) AS i
354
+ ),
355
+ "updatedAt" = NOW()
356
+ `, args.bucketStart, args.route, args.statusBucket, args.durationMs, JSON.stringify(histogram));
357
+ }
358
+ catch (err) {
359
+ // Throttle the log so a misconfigured DB doesn't drown the
360
+ // logger. We rate-limit *log lines*, not metric writes — every
361
+ // request still attempts a write, but only one error/minute
362
+ // surfaces in console.
363
+ rateLimitedWarn('recordApiRequest write failed', err instanceof Error ? err.message : String(err));
364
+ }
365
+ }
366
+ let lastWarnAt = 0;
367
+ function rateLimitedWarn(msg, detail) {
368
+ const now = Date.now();
369
+ if (now - lastWarnAt < 60_000)
370
+ return;
371
+ lastWarnAt = now;
372
+ logger.warn(msg, { detail });
373
+ }
374
+ //# sourceMappingURL=api-metrics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-metrics.js","sourceRoot":"","sources":["../../src/diagnostics/api-metrics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,OAAO,EAAE,KAAK,EAAE,MAAM,UAAU,CAAA;AAEhC,MAAM,MAAM,GAAG,YAAY,CAAC,aAAa,CAAC,CAAA;AAE1C,8EAA8E;AAE9E;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,CAAU,CAAA;AAErF,MAAM,CAAC,MAAM,gBAAgB,GAAG,eAAe,CAAC,MAAM,GAAG,CAAC,CAAA,CAAC,KAAK;AAYhE,8EAA8E;AAE9E;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,MAAc;IAC3C,IAAI,MAAM,IAAI,GAAG,IAAI,MAAM,GAAG,GAAG;QAAE,OAAO,KAAK,CAAA;IAC/C,IAAI,MAAM,IAAI,GAAG,IAAI,MAAM,GAAG,GAAG;QAAE,OAAO,KAAK,CAAA;IAC/C,IAAI,MAAM,IAAI,GAAG,IAAI,MAAM,GAAG,GAAG;QAAE,OAAO,KAAK,CAAA;IAC/C,IAAI,MAAM,IAAI,GAAG,IAAI,MAAM,GAAG,GAAG;QAAE,OAAO,KAAK,CAAA;IAC/C,OAAO,OAAO,CAAA;AAChB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,UAAkB;IACrD,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,UAAU,GAAG,CAAC;QAAE,OAAO,CAAC,CAAA;IAC5D,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,eAAe,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QAChD,IAAI,UAAU,GAAG,eAAe,CAAC,CAAC,CAAE;YAAE,OAAO,CAAC,CAAA;IAChD,CAAC;IACD,OAAO,eAAe,CAAC,MAAM,CAAA,CAAC,kBAAkB;AAClD,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,UAAkB;IACjD,MAAM,GAAG,GAAG,IAAI,KAAK,CAAS,gBAAgB,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACvD,GAAG,CAAC,oBAAoB,CAAC,UAAU,CAAC,CAAC,GAAG,CAAC,CAAA;IACzC,OAAO,GAAG,CAAA;AACZ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAAC,CAA8B,EAAE,CAAW;IACzE,MAAM,GAAG,GAAG,IAAI,KAAK,CAAS,gBAAgB,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACvD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,gBAAgB,EAAE,CAAC,EAAE,EAAE,CAAC;QAC1C,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAA;IACtC,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,cAAc,CAAC,MAAqB,IAAI,IAAI,EAAE;IAC5D,MAAM,EAAE,GAAG,OAAO,GAAG,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,EAAE,CAAA;IACxD,OAAO,IAAI,IAAI,CAAC,EAAE,GAAG,CAAC,EAAE,GAAG,MAAM,CAAC,CAAC,CAAA;AACrC,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,kBAAkB,CAAC,SAAmB,EAAE,UAAkB;IACxE,MAAM,KAAK,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAA;IAClD,IAAI,KAAK,KAAK,CAAC;QAAE,OAAO,CAAC,CAAA;IACzB,MAAM,MAAM,GAAG,KAAK,GAAG,UAAU,CAAA;IACjC,IAAI,GAAG,GAAG,CAAC,CAAA;IACX,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,gBAAgB,EAAE,CAAC,EAAE,EAAE,CAAC;QAC1C,GAAG,IAAI,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;QACxB,IAAI,GAAG,IAAI,MAAM,EAAE,CAAC;YAClB,IAAI,CAAC,GAAG,eAAe,CAAC,MAAM;gBAAE,OAAO,eAAe,CAAC,CAAC,CAAE,CAAA;YAC1D,OAAO,eAAe,CAAC,eAAe,CAAC,MAAM,GAAG,CAAC,CAAE,GAAG,CAAC,CAAA,CAAC,oBAAoB;QAC9E,CAAC;IACH,CAAC;IACD,OAAO,CAAC,CAAA;AACV,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,cAAc,CAAC,QAAgB;IAC7C,qDAAqD;IACrD,IAAI,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAA;IACrC,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IACvD,oEAAoE;IACpE,kEAAkE;IAClE,IAAI,CAAC,CAAC,UAAU,CAAC,UAAU,CAAC;QAAE,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,IAAI,GAAG,CAAA;IAEnE,sDAAsD;IACtD,IAAI,mBAAmB,CAAC,GAAG,CAAC,CAAC,CAAC;QAAE,OAAO,CAAC,CAAA;IAExC,6CAA6C;IAC7C,KAAK,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,IAAI,cAAc,EAAE,CAAC;QACtD,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;YAAE,OAAO,CAAC,CAAC,OAAO,CAAC,OAAO,EAAE,WAAW,CAAC,CAAA;IAC7D,CAAC;IAED,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAAC,eAAuB;IAClD,OAAO,CAAC,WAAW,CAAC,GAAG,CAAC,eAAe,CAAC,CAAA;AAC1C,CAAC;AAED,8EAA8E;AAE9E;;;;GAIG;AACH,MAAM,mBAAmB,GAAG,IAAI,GAAG,CAAS;IAC1C,GAAG;IACH,SAAS;IACT,QAAQ;IACR,qBAAqB;IACrB,uBAAuB;IACvB,aAAa;IACb,cAAc;IACd,UAAU;IACV,YAAY;IACZ,uBAAuB;IACvB,sBAAsB;IACtB,kBAAkB;IAClB,eAAe;IACf,qBAAqB;IACrB,SAAS;IACT,QAAQ;IACR,WAAW;IACX,gBAAgB;IAChB,uBAAuB;IACvB,yBAAyB;IACzB,QAAQ;IACR,eAAe;IACf,QAAQ;IACR,aAAa;IACb,cAAc;IACd,YAAY;IACZ,cAAc;IACd,iBAAiB;IACjB,iBAAiB;IACjB,WAAW;IACX,gBAAgB;CACjB,CAAC,CAAA;AAEF;;;;GAIG;AACH,MAAM,WAAW,GAAG,IAAI,GAAG,CAAS;IAClC,QAAQ;IACR,qBAAqB;IACrB,uBAAuB;IACvB,SAAS;IACT,uBAAuB,EAAE,sDAAsD;CAChF,CAAC,CAAA;AAEF,MAAM,cAAc,GAAoD;IACtE;QACE,OAAO,EAAE,wCAAwC;QACjD,WAAW,EAAE,gCAAgC;KAC9C;IACD;QACE,OAAO,EAAE,qDAAqD;QAC9D,WAAW,EAAE,iCAAiC;KAC/C;IACD;QACE,OAAO,EAAE,0CAA0C;QACnD,WAAW,EAAE,kCAAkC;KAChD;IACD,EAAE,OAAO,EAAE,+BAA+B,EAAE,WAAW,EAAE,wBAAwB,EAAE;IACnF,EAAE,OAAO,EAAE,wBAAwB,EAAE,WAAW,EAAE,oBAAoB,EAAE;IACxE,EAAE,OAAO,EAAE,oBAAoB,EAAE,WAAW,EAAE,gBAAgB,EAAE;IAChE,EAAE,OAAO,EAAE,kBAAkB,EAAE,WAAW,EAAE,YAAY,EAAE;IAC1D,EAAE,OAAO,EAAE,0BAA0B,EAAE,WAAW,EAAE,mBAAmB,EAAE;IACzE,EAAE,OAAO,EAAE,2CAA2C,EAAE,WAAW,EAAE,wBAAwB,EAAE;IAC/F,EAAE,OAAO,EAAE,kBAAkB,EAAE,WAAW,EAAE,YAAY,EAAE;IAC1D,EAAE,OAAO,EAAE,kBAAkB,EAAE,WAAW,EAAE,YAAY,EAAE;IAC1D,EAAE,OAAO,EAAE,qBAAqB,EAAE,WAAW,EAAE,eAAe,EAAE;IAChE,EAAE,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,gBAAgB,EAAE;IAClE,EAAE,OAAO,EAAE,wBAAwB,EAAE,WAAW,EAAE,kBAAkB,EAAE;IACtE,EAAE,OAAO,EAAE,2BAA2B,EAAE,WAAW,EAAE,qBAAqB,EAAE;IAC5E,EAAE,OAAO,EAAE,2BAA2B,EAAE,WAAW,EAAE,qBAAqB,EAAE;IAC5E,EAAE,OAAO,EAAE,8BAA8B,EAAE,WAAW,EAAE,eAAe,EAAE;IACzE,EAAE,OAAO,EAAE,gCAAgC,EAAE,WAAW,EAAE,yBAAyB,EAAE;IACrF,EAAE,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,YAAY,EAAE;IACrD,EAAE,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE;IAClD,EAAE,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE;IAClD,EAAE,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,eAAe,EAAE;IACzD,EAAE,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,cAAc,EAAE;IAC1D,EAAE,OAAO,EAAE,qBAAqB,EAAE,WAAW,EAAE,mBAAmB,EAAE;IACpE,EAAE,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,SAAS,EAAE;IAChD,EAAE,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,WAAW,EAAE;IACpD,EAAE,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,UAAU,EAAE;CAC7D,CAAA;AAED,8EAA8E;AAE9E;;;;;;;;;;GAUG;AACH,MAAM,UAAU,gBAAgB,CAAC,MAAuB;IACtD,MAAM,KAAK,GAAG,cAAc,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;IAC7C,IAAI,CAAC,YAAY,CAAC,KAAK,CAAC;QAAE,OAAM;IAEhC,+DAA+D;IAC/D,8DAA8D;IAC9D,KAAK,iBAAiB,CAAC;QACrB,WAAW,EAAE,cAAc,EAAE;QAC7B,KAAK;QACL,YAAY,EAAE,cAAc,CAAC,MAAM,CAAC,MAAM,CAAC;QAC3C,UAAU,EAAE,MAAM,CAAC,UAAU;KAC9B,CAAC,CAAA;AACJ,CAAC;AASD,KAAK,UAAU,iBAAiB,CAAC,IAAe;IAC9C,IAAI,EAAO,CAAA;IACX,IAAI,CAAC;QACH,EAAE,GAAG,KAAK,EAAE,CAAA;IACd,CAAC;IAAC,MAAM,CAAC;QACP,6DAA6D;QAC7D,2DAA2D;QAC3D,oDAAoD;QACpD,OAAM;IACR,CAAC;IAED,2DAA2D;IAC3D,iEAAiE;IACjE,oEAAoE;IACpE,iBAAiB;IACjB,IAAI,CAAC,EAAE,IAAI,OAAO,EAAE,CAAC,iBAAiB,KAAK,UAAU,IAAI,CAAC,CAAC,kBAAkB,IAAI,EAAE,CAAC,EAAE,CAAC;QACrF,OAAM;IACR,CAAC;IAED,MAAM,SAAS,GAAG,gBAAgB,CAAC,IAAI,CAAC,UAAU,CAAC,CAAA;IAEnD,IAAI,CAAC;QACH,gEAAgE;QAChE,6DAA6D;QAC7D,wDAAwD;QACxD,EAAE;QACF,4DAA4D;QAC5D,8DAA8D;QAC9D,6DAA6D;QAC7D,0DAA0D;QAC1D,0BAA0B;QAC1B,MAAM,EAAE,CAAC,iBAAiB,CACxB;;;;;;;;;;;;;;;;;;;;;;;;0CAwBoC,gBAAgB,GAAG,CAAC;;;OAGvD,EACD,IAAI,CAAC,WAAW,EAChB,IAAI,CAAC,KAAK,EACV,IAAI,CAAC,YAAY,EACjB,IAAI,CAAC,UAAU,EACf,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,CAC1B,CAAA;IACH,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,2DAA2D;QAC3D,+DAA+D;QAC/D,4DAA4D;QAC5D,uBAAuB;QACvB,eAAe,CACb,+BAA+B,EAC/B,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CACjD,CAAA;IACH,CAAC;AACH,CAAC;AAED,IAAI,UAAU,GAAG,CAAC,CAAA;AAClB,SAAS,eAAe,CAAC,GAAW,EAAE,MAAc;IAClD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAA;IACtB,IAAI,GAAG,GAAG,UAAU,GAAG,MAAM;QAAE,OAAM;IACrC,UAAU,GAAG,GAAG,CAAA;IAChB,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,MAAM,EAAE,CAAC,CAAA;AAC9B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-core",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/actuate-media/actuatecms.git",
@@ -0,0 +1,74 @@
1
+ -- Dashboard metrics & content health
2
+ --
3
+ -- Adds two tables that back the redesigned admin Dashboard:
4
+ -- * actuate_api_request_metrics — per-minute pre-aggregated request
5
+ -- counters with a fixed log-scale latency histogram, sized to keep
6
+ -- a week of traffic in O(minutes × routes × status-buckets) rows.
7
+ -- * actuate_content_issues — persisted content-quality issues
8
+ -- produced by the nightly content-health scanner. Upserted by
9
+ -- (documentId, type) so the scanner is idempotent.
10
+ --
11
+ -- Both tables are safe to add to a populated database: they have no
12
+ -- backfill dependency and no constraints that touch existing rows.
13
+
14
+ -- ---------------------------------------------------------------------------
15
+ -- ContentIssueType enum
16
+ -- ---------------------------------------------------------------------------
17
+ CREATE TYPE "ContentIssueType" AS ENUM (
18
+ 'MISSING_META_DESCRIPTION',
19
+ 'MISSING_ALT_TEXT',
20
+ 'BROKEN_INTERNAL_LINK',
21
+ 'OUTDATED_CONTENT'
22
+ );
23
+
24
+ -- ---------------------------------------------------------------------------
25
+ -- actuate_api_request_metrics
26
+ -- ---------------------------------------------------------------------------
27
+ CREATE TABLE "actuate_api_request_metrics" (
28
+ "id" TEXT NOT NULL,
29
+ "bucketStart" TIMESTAMP(3) NOT NULL,
30
+ "route" TEXT NOT NULL,
31
+ "statusBucket" TEXT NOT NULL,
32
+ "count" INTEGER NOT NULL DEFAULT 0,
33
+ "sumLatencyMs" DOUBLE PRECISION NOT NULL DEFAULT 0,
34
+ "maxLatencyMs" DOUBLE PRECISION NOT NULL DEFAULT 0,
35
+ "latencyHistogram" JSONB NOT NULL,
36
+ "updatedAt" TIMESTAMP(3) NOT NULL,
37
+
38
+ CONSTRAINT "actuate_api_request_metrics_pkey" PRIMARY KEY ("id")
39
+ );
40
+
41
+ CREATE UNIQUE INDEX "actuate_api_request_metrics_bucketStart_route_statusBucket_key"
42
+ ON "actuate_api_request_metrics" ("bucketStart", "route", "statusBucket");
43
+
44
+ CREATE INDEX "actuate_api_request_metrics_bucketStart_idx"
45
+ ON "actuate_api_request_metrics" ("bucketStart");
46
+
47
+ -- ---------------------------------------------------------------------------
48
+ -- actuate_content_issues
49
+ -- ---------------------------------------------------------------------------
50
+ CREATE TABLE "actuate_content_issues" (
51
+ "id" TEXT NOT NULL,
52
+ "documentId" TEXT NOT NULL,
53
+ "type" "ContentIssueType" NOT NULL,
54
+ "count" INTEGER NOT NULL DEFAULT 1,
55
+ "details" JSONB,
56
+ "firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
57
+ "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
58
+
59
+ CONSTRAINT "actuate_content_issues_pkey" PRIMARY KEY ("id"),
60
+ CONSTRAINT "actuate_content_issues_documentId_fkey"
61
+ FOREIGN KEY ("documentId")
62
+ REFERENCES "actuate_documents"("id")
63
+ ON DELETE CASCADE
64
+ ON UPDATE CASCADE
65
+ );
66
+
67
+ CREATE UNIQUE INDEX "actuate_content_issues_documentId_type_key"
68
+ ON "actuate_content_issues" ("documentId", "type");
69
+
70
+ CREATE INDEX "actuate_content_issues_type_idx"
71
+ ON "actuate_content_issues" ("type");
72
+
73
+ CREATE INDEX "actuate_content_issues_documentId_idx"
74
+ ON "actuate_content_issues" ("documentId");