adonisjs-server-stats 1.17.0 → 1.18.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 (66) hide show
  1. package/README.md +11 -6
  2. package/dist/core/core/define-config-helpers.d.ts +1 -0
  3. package/dist/core/core/types-diagnostics.d.ts +2 -0
  4. package/dist/core/debug/types.d.ts +2 -0
  5. package/dist/core/types.d.ts +31 -3
  6. package/dist/react/{CacheSection-C-X2Tnca.js → CacheSection-B-lSIN8Q.js} +1 -1
  7. package/dist/react/{CacheTab-D55mfmeE.js → CacheTab-BN0QgPXz.js} +1 -1
  8. package/dist/react/{ConfigSection-C-HGnZfd.js → ConfigSection-u_TSfb9f.js} +1 -1
  9. package/dist/react/{ConfigTab-CNnsOXBh.js → ConfigTab-au26s-ON.js} +1 -1
  10. package/dist/react/{CustomPaneTab-D-267UVd.js → CustomPaneTab-qvMnh8ss.js} +1 -1
  11. package/dist/react/{EmailsSection-Bqw4DI4r.js → EmailsSection-HfEMeRex.js} +1 -1
  12. package/dist/react/{EmailsTab-CkHOhj2F.js → EmailsTab-BWwq-CxB.js} +1 -1
  13. package/dist/react/{EventsSection-CMeyjzVC.js → EventsSection-CA9b15dJ.js} +1 -1
  14. package/dist/react/{EventsTab-DgS2v1HX.js → EventsTab-CgSdHbKU.js} +1 -1
  15. package/dist/react/{InternalsContent-C9lIA92C.js → InternalsContent-CI7HvBtH.js} +51 -47
  16. package/dist/react/{InternalsSection-lOv_UcSV.js → InternalsSection-IEeWXJqP.js} +1 -1
  17. package/dist/react/{InternalsTab-BygoSorC.js → InternalsTab-CoVLHQbF.js} +1 -1
  18. package/dist/react/{JobsSection-oWH6pbrG.js → JobsSection-DgsS_VwU.js} +1 -1
  19. package/dist/react/{JobsTab-zdxX_as-.js → JobsTab-CzwLSRUC.js} +1 -1
  20. package/dist/react/{LogEntryRow-Cn8Ee_X0.js → LogEntryRow-VCCzwD1H.js} +1 -1
  21. package/dist/react/{LogsSection-XmiI2MMs.js → LogsSection-BujNn_cO.js} +2 -2
  22. package/dist/react/{LogsTab-ixusY4v7.js → LogsTab-BtD1h7DM.js} +2 -2
  23. package/dist/react/{OverviewSection-CvhObTLP.js → OverviewSection-B28em0gQ.js} +1 -1
  24. package/dist/react/{QueriesSection-BvledwBI.js → QueriesSection-B0tFn_Dc.js} +1 -1
  25. package/dist/react/{QueriesTab-smQQJvJa.js → QueriesTab-DJY7aavR.js} +1 -1
  26. package/dist/react/{RequestsSection-DKuYpViv.js → RequestsSection-DPmYM7tq.js} +2 -2
  27. package/dist/react/{RoutesSection-5RNVurQ6.js → RoutesSection-Ck-RcFVS.js} +1 -1
  28. package/dist/react/{RoutesTab-CpXNO-Rx.js → RoutesTab-Ch3PDLzo.js} +1 -1
  29. package/dist/react/{SplitPaneWrapper-DQvJnsuk.js → SplitPaneWrapper-BZ6VrBr-.js} +1 -1
  30. package/dist/react/{TimelineTab-LHQHHKkO.js → TimelineTab-_byxh2mJ.js} +2 -2
  31. package/dist/react/{index-LlFvooUN.js → index-De7nsWBN.js} +2 -2
  32. package/dist/react/index.js +1 -1
  33. package/dist/src/dashboard/dashboard_store.js +4 -12
  34. package/dist/src/dashboard/knex_factory.js +8 -0
  35. package/dist/src/dashboard/migrator.d.ts +7 -2
  36. package/dist/src/dashboard/migrator.js +140 -6
  37. package/dist/src/dashboard/migrator_tables.js +4 -0
  38. package/dist/src/dashboard/storage_stats.d.ts +5 -0
  39. package/dist/src/dashboard/storage_stats.js +15 -1
  40. package/dist/src/debug/types.d.ts +2 -0
  41. package/dist/src/define_config.js +2 -0
  42. package/dist/src/edge/client/dashboard.js +2 -2
  43. package/dist/src/edge/client/debug-panel-deferred.js +1 -1
  44. package/dist/src/edge/client-vue/dashboard.js +3 -3
  45. package/dist/src/edge/client-vue/debug-panel-deferred.js +2 -2
  46. package/dist/src/provider/dashboard_setup.js +14 -0
  47. package/dist/src/provider/diagnostics.d.ts +2 -0
  48. package/dist/src/provider/diagnostics.js +1 -0
  49. package/dist/src/provider/server_stats_provider.d.ts +1 -0
  50. package/dist/src/stubs/config.stub +4 -0
  51. package/dist/src/types.d.ts +31 -3
  52. package/dist/vue/{CacheSection-Bm0goqX6.js → CacheSection-z2TQRez9.js} +1 -1
  53. package/dist/vue/{ConfigSection-BrYslIMN.js → ConfigSection-CFEaig69.js} +1 -1
  54. package/dist/vue/{EmailsSection-B8N5vaWA.js → EmailsSection-CYohBo62.js} +1 -1
  55. package/dist/vue/{EventsSection-D2Y_xt0m.js → EventsSection-B_TuVrGj.js} +1 -1
  56. package/dist/vue/{InternalsSection-jhTCuK3j.js → InternalsSection-B1P2kNT-.js} +92 -84
  57. package/dist/vue/{InternalsTab-Z3c82glB.js → InternalsTab-CRI-IFTo.js} +155 -147
  58. package/dist/vue/{JobsSection-cEKqELLC.js → JobsSection-AjkEzyiv.js} +1 -1
  59. package/dist/vue/{LogsSection-C3H2c0S3.js → LogsSection-DZD0CCDi.js} +1 -1
  60. package/dist/vue/{OverviewSection-I2swuHDa.js → OverviewSection-BEc4Gktf.js} +1 -1
  61. package/dist/vue/{QueriesSection-CF1IE3SN.js → QueriesSection-BMImGXTn.js} +1 -1
  62. package/dist/vue/{RequestsSection-CuwgXzmU.js → RequestsSection-DOI2MUFX.js} +1 -1
  63. package/dist/vue/{RoutesSection-BhJCF7Rn.js → RoutesSection-C1sxt-AW.js} +1 -1
  64. package/dist/vue/{index-DUsImuIN.js → index-BfPAAjL5.js} +2 -2
  65. package/dist/vue/index.js +1 -1
  66. package/package.json +1 -1
@@ -2,11 +2,11 @@ import { jsx as e, jsxs as t } from "react/jsx-runtime";
2
2
  import { useState as i, useMemo as q, useEffect as I, useCallback as O } from "react";
3
3
  import { formatDuration as m, durationClassName as j } from "adonisjs-server-stats/core";
4
4
  import { u as U } from "./useApiClient-CtEG7rHs.js";
5
- import { u as W, M as D, S as L } from "./index-LlFvooUN.js";
5
+ import { u as W, M as D, S as L } from "./index-De7nsWBN.js";
6
6
  import { T as _ } from "./TimeAgoCell-o3KigGfM.js";
7
7
  import { u as z } from "./useResizableTable-CNJmACdt.js";
8
8
  import { F as G } from "./FilterBar-CQ7bD669.js";
9
- import { S as K, R as V } from "./SplitPaneWrapper-DQvJnsuk.js";
9
+ import { S as K, R as V } from "./SplitPaneWrapper-BZ6VrBr-.js";
10
10
  const S = {
11
11
  request: "#1e3a5f",
12
12
  middleware: "rgba(30, 58, 95, 0.7)",
@@ -437,7 +437,7 @@ function K({
437
437
  }
438
438
  );
439
439
  }
440
- const we = T(() => import("./QueriesTab-smQQJvJa.js")), ke = T(() => import("./EventsTab-DgS2v1HX.js")), ye = T(() => import("./EmailsTab-CkHOhj2F.js")), Ne = T(() => import("./RoutesTab-CpXNO-Rx.js")), Ce = T(() => import("./LogsTab-ixusY4v7.js")), Se = T(() => import("./TimelineTab-LHQHHKkO.js")), Le = T(() => import("./CacheTab-D55mfmeE.js")), Te = T(() => import("./JobsTab-zdxX_as-.js")), Ee = T(() => import("./ConfigTab-CNnsOXBh.js")), $e = T(() => import("./InternalsTab-BygoSorC.js")), xe = T(() => import("./CustomPaneTab-D-267UVd.js"));
440
+ const we = T(() => import("./QueriesTab-DJY7aavR.js")), ke = T(() => import("./EventsTab-CgSdHbKU.js")), ye = T(() => import("./EmailsTab-BWwq-CxB.js")), Ne = T(() => import("./RoutesTab-Ch3PDLzo.js")), Ce = T(() => import("./LogsTab-BtD1h7DM.js")), Se = T(() => import("./TimelineTab-_byxh2mJ.js")), Le = T(() => import("./CacheTab-BN0QgPXz.js")), Te = T(() => import("./JobsTab-CzwLSRUC.js")), Ee = T(() => import("./ConfigTab-au26s-ON.js")), $e = T(() => import("./InternalsTab-CoVLHQbF.js")), xe = T(() => import("./CustomPaneTab-qvMnh8ss.js"));
441
441
  function ss(e) {
442
442
  const {
443
443
  defaultOpen: r = !1,
@@ -756,7 +756,7 @@ const je = X(void 0), Pe = [
756
756
  "jobs",
757
757
  "config",
758
758
  "internals"
759
- ], He = T(() => import("./OverviewSection-CvhObTLP.js")), Ue = T(() => import("./RequestsSection-DKuYpViv.js")), qe = T(() => import("./QueriesSection-BvledwBI.js")), Fe = T(() => import("./EventsSection-CMeyjzVC.js")), ze = T(() => import("./RoutesSection-5RNVurQ6.js")), Ae = T(() => import("./LogsSection-XmiI2MMs.js")), We = T(() => import("./EmailsSection-Bqw4DI4r.js")), Ke = T(() => import("./CacheSection-C-X2Tnca.js")), Ve = T(() => import("./JobsSection-oWH6pbrG.js")), Je = T(() => import("./ConfigSection-C-HGnZfd.js")), Ge = T(() => import("./InternalsSection-lOv_UcSV.js"));
759
+ ], He = T(() => import("./OverviewSection-B28em0gQ.js")), Ue = T(() => import("./RequestsSection-DPmYM7tq.js")), qe = T(() => import("./QueriesSection-B0tFn_Dc.js")), Fe = T(() => import("./EventsSection-CA9b15dJ.js")), ze = T(() => import("./RoutesSection-Ck-RcFVS.js")), Ae = T(() => import("./LogsSection-BujNn_cO.js")), We = T(() => import("./EmailsSection-HfEMeRex.js")), Ke = T(() => import("./CacheSection-B-lSIN8Q.js")), Ve = T(() => import("./JobsSection-DgsS_VwU.js")), Je = T(() => import("./ConfigSection-u_TSfb9f.js")), Ge = T(() => import("./InternalsSection-IEeWXJqP.js"));
760
760
  function ts(e) {
761
761
  const {
762
762
  baseUrl: r = "",
@@ -1,4 +1,4 @@
1
- import { B as s, D as t, c as r, J as u, M as g, d as o, d, S as D, T as S, e as h, a as B, u as T, f as b, g as l, h as m } from "./index-LlFvooUN.js";
1
+ import { B as s, D as t, c as r, J as u, M as g, d as o, d, S as D, T as S, e as h, a as B, u as T, f as b, g as l, h as m } from "./index-De7nsWBN.js";
2
2
  export {
3
3
  s as Badge,
4
4
  t as DashboardPage,
@@ -10,7 +10,7 @@ import { autoMigrate, runRetentionCleanup } from './migrator.js';
10
10
  import { fetchOverviewMetrics, fetchChartData, fetchOverviewWidgets, fetchSparklineData, } from './overview_store_queries.js';
11
11
  import { queryRequests, queryQueries, queryQueriesGrouped, queryEvents, queryEmails, queryEmailHtml, queryLogs, queryTraces, queryTraceDetail, queryRequestDetail, } from './read_queries.js';
12
12
  import { fetchSavedFilters, insertSavedFilter, removeSavedFilter } from './saved_filter_queries.js';
13
- import { fetchStorageStats } from './storage_stats.js';
13
+ import { fetchStorageStats, emptyStorageStats } from './storage_stats.js';
14
14
  import { resetWriteWarnings } from './write_queue.js';
15
15
  const EMPTY_PAGINATED = (p, pp) => ({
16
16
  data: [],
@@ -63,7 +63,7 @@ export class DashboardStore {
63
63
  const cleanup = async () => {
64
64
  try {
65
65
  if (this.db) {
66
- await runRetentionCleanup(this.db, this.config.retentionDays);
66
+ await runRetentionCleanup(this.db, this.config.retentionDays, this.config.maxDbSizeMb);
67
67
  this.lastCleanupAt = Date.now();
68
68
  log.info('dashboard: retention cleanup complete');
69
69
  }
@@ -110,17 +110,8 @@ export class DashboardStore {
110
110
  return this.db !== null;
111
111
  }
112
112
  async getStorageStats() {
113
- const empty = {
114
- ready: false,
115
- dbPath: this.config.dbPath,
116
- fileSizeMb: 0,
117
- walSizeMb: 0,
118
- retentionDays: this.config.retentionDays,
119
- tables: [],
120
- lastCleanupAt: null,
121
- };
122
113
  if (!this.db)
123
- return empty;
114
+ return emptyStorageStats(this.config.dbPath, this.config.retentionDays, this.config.maxDbSizeMb);
124
115
  if (this.cachedStorageStats &&
125
116
  Date.now() - this.cachedStorageStats.cachedAt < DashboardStore.STORAGE_TTL)
126
117
  return this.cachedStorageStats.data;
@@ -130,6 +121,7 @@ export class DashboardStore {
130
121
  dbFilePath: this.dbFilePath,
131
122
  dbPath: this.config.dbPath,
132
123
  retentionDays: this.config.retentionDays,
124
+ maxDbSizeMb: this.config.maxDbSizeMb,
133
125
  lastCleanupAt: this.lastCleanupAt,
134
126
  onResult: (stats) => {
135
127
  this.cachedStorageStats = { data: stats, cachedAt: Date.now() };
@@ -8,7 +8,14 @@ import { log } from '../utils/logger.js';
8
8
  // ---------------------------------------------------------------------------
9
9
  // PRAGMA constants
10
10
  // ---------------------------------------------------------------------------
11
+ // auto_vacuum must come FIRST: it only takes effect on a database whose
12
+ // header hasn't been written yet, and even `journal_mode=WAL` initializes
13
+ // the header. On existing databases it only takes effect after a VACUUM
14
+ // (which runRetentionCleanup performs, guarded, when the file is mostly
15
+ // dead pages). Incremental mode lets retention cleanup return deleted
16
+ // pages to the OS in small chunks instead of growing the file forever.
11
17
  export const SQLITE_PRAGMAS = [
18
+ 'auto_vacuum=INCREMENTAL',
12
19
  'journal_mode=WAL',
13
20
  'foreign_keys=ON',
14
21
  'synchronous=NORMAL',
@@ -17,6 +24,7 @@ export const SQLITE_PRAGMAS = [
17
24
  'temp_store=MEMORY',
18
25
  ];
19
26
  const PRAGMA_STATEMENTS_FOR_POOL = [
27
+ 'auto_vacuum = INCREMENTAL',
20
28
  'journal_mode = WAL',
21
29
  'foreign_keys = ON',
22
30
  'synchronous = NORMAL',
@@ -11,12 +11,17 @@ import type { Knex } from 'knex';
11
11
  */
12
12
  export declare function autoMigrate(db: Knex): Promise<void>;
13
13
  /**
14
- * Delete records older than `retentionDays` from all tables.
14
+ * Delete records older than `retentionDays` from all tables, then enforce
15
+ * the database size cap.
15
16
  *
16
17
  * Foreign-key cascades on `server_stats_requests` handle the child
17
18
  * tables (queries, events, traces). Standalone tables (logs, emails,
18
19
  * metrics, saved_filters) are pruned individually.
19
20
  *
21
+ * `maxDbSizeMb` bounds the *live* data size: when exceeded, the globally
22
+ * oldest rows are deleted — regardless of age — until usage drops below
23
+ * ~90% of the cap. `0` disables the cap.
24
+ *
20
25
  * Yields between each DELETE so the event loop stays responsive.
21
26
  */
22
- export declare function runRetentionCleanup(db: Knex, retentionDays: number): Promise<void>;
27
+ export declare function runRetentionCleanup(db: Knex, retentionDays: number, maxDbSizeMb?: number): Promise<void>;
@@ -27,15 +27,20 @@ export async function autoMigrate(db) {
27
27
  await migrateSavedFilters(db);
28
28
  }
29
29
  /**
30
- * Delete records older than `retentionDays` from all tables.
30
+ * Delete records older than `retentionDays` from all tables, then enforce
31
+ * the database size cap.
31
32
  *
32
33
  * Foreign-key cascades on `server_stats_requests` handle the child
33
34
  * tables (queries, events, traces). Standalone tables (logs, emails,
34
35
  * metrics, saved_filters) are pruned individually.
35
36
  *
37
+ * `maxDbSizeMb` bounds the *live* data size: when exceeded, the globally
38
+ * oldest rows are deleted — regardless of age — until usage drops below
39
+ * ~90% of the cap. `0` disables the cap.
40
+ *
36
41
  * Yields between each DELETE so the event loop stays responsive.
37
42
  */
38
- export async function runRetentionCleanup(db, retentionDays) {
43
+ export async function runRetentionCleanup(db, retentionDays, maxDbSizeMb = 0) {
39
44
  // Use string interpolation instead of parameterized bindings.
40
45
  // Knex + better-sqlite3 can hang on parameterized db.raw() calls,
41
46
  // while non-parameterized queries (used in migrations) work fine.
@@ -53,7 +58,12 @@ export async function runRetentionCleanup(db, retentionDays) {
53
58
  await yieldToEventLoop();
54
59
  await batchDelete(db, 'server_stats_metrics', cutoff);
55
60
  await yieldToEventLoop();
56
- // Reclaim space and update query planner statistics
61
+ if (maxDbSizeMb > 0)
62
+ await enforceSizeCap(db, maxDbSizeMb);
63
+ // Return deleted pages to the OS — PRAGMA optimize alone only updates
64
+ // planner stats, leaving the file at its high-water mark forever.
65
+ await reclaimFreeSpace(db);
66
+ // Update query planner statistics
57
67
  await db.raw('PRAGMA optimize');
58
68
  }
59
69
  catch (err) {
@@ -72,10 +82,134 @@ async function batchDelete(db, table, cutoff) {
72
82
  let hasMore = true;
73
83
  while (hasMore) {
74
84
  await db.raw(`DELETE FROM ${table} WHERE rowid IN (SELECT rowid FROM ${table} WHERE created_at < ${cutoff} LIMIT 1000)`);
75
- const remaining = await db.raw(`SELECT COUNT(*) as cnt FROM ${table} WHERE created_at < ${cutoff} LIMIT 1`);
76
- const cnt = remaining?.[0]?.cnt ?? 0;
77
- hasMore = cnt > 0;
85
+ // changes() reports the last DELETE's row count on this connection
86
+ // (pool is min:1/max:1). Re-counting the remaining backlog here instead
87
+ // made total work quadratic in backlog size.
88
+ const deleted = await pragmaNumber(db, 'SELECT changes() AS n');
89
+ hasMore = deleted === 1000;
78
90
  if (hasMore)
79
91
  await yieldToEventLoop();
80
92
  }
81
93
  }
94
+ /** Root tables pruned by the size cap; requests cascades to queries/events/traces. */
95
+ const SIZE_CAP_TABLES = [
96
+ 'server_stats_requests',
97
+ 'server_stats_logs',
98
+ 'server_stats_emails',
99
+ 'server_stats_metrics',
100
+ ];
101
+ /**
102
+ * Bytes of live data: total pages minus freelist pages. Freed pages are
103
+ * excluded because reclaimFreeSpace returns them to the OS afterwards.
104
+ */
105
+ async function liveSizeBytes(db, pageSize) {
106
+ const pages = await pragmaNumber(db, 'PRAGMA page_count');
107
+ const freelist = await pragmaNumber(db, 'PRAGMA freelist_count');
108
+ return (pages - freelist) * pageSize;
109
+ }
110
+ /**
111
+ * Keep live data under `maxSizeMb` by deleting the globally oldest rows,
112
+ * whatever their age — a size cap effectively shortens the retention
113
+ * window when write volume outruns `retentionDays`.
114
+ *
115
+ * Prunes down to ~90% of the cap so the hourly cleanup doesn't re-trigger
116
+ * on every pass. Each round deletes 1000 rows from whichever root table
117
+ * currently holds the oldest data, so mixed workloads (trace-heavy vs
118
+ * log-heavy) shed their actual oldest history first.
119
+ */
120
+ async function enforceSizeCap(db, maxSizeMb) {
121
+ const budget = maxSizeMb * 1024 * 1024;
122
+ const pageSize = await pragmaNumber(db, 'PRAGMA page_size');
123
+ if ((await liveSizeBytes(db, pageSize)) <= budget)
124
+ return;
125
+ const { log } = await import('../utils/logger.js');
126
+ log.info(`dashboard: database over the ${maxSizeMb} MB size cap — pruning oldest records`);
127
+ const target = budget * 0.9;
128
+ // 10k rounds × 1000 rows bounds one pass; the next hourly pass continues.
129
+ let guard = 10_000;
130
+ while (guard-- > 0 && (await liveSizeBytes(db, pageSize)) > target) {
131
+ const table = await findTableWithOldestRow(db);
132
+ if (!table)
133
+ break;
134
+ await db.raw(`DELETE FROM ${table} WHERE rowid IN (SELECT rowid FROM ${table} ORDER BY created_at LIMIT 1000)`);
135
+ const deleted = await pragmaNumber(db, 'SELECT changes() AS n');
136
+ if (deleted === 0)
137
+ break;
138
+ await yieldToEventLoop();
139
+ }
140
+ }
141
+ /** The root table whose oldest row is globally oldest, or null when all are empty. */
142
+ async function findTableWithOldestRow(db) {
143
+ let best = null;
144
+ let bestTs = '';
145
+ for (const table of SIZE_CAP_TABLES) {
146
+ const rows = (await db.raw(`SELECT MIN(created_at) AS ts FROM ${table}`));
147
+ const ts = rows?.[0]?.ts;
148
+ if (ts && (best === null || ts < bestTs)) {
149
+ best = table;
150
+ bestTs = ts;
151
+ }
152
+ }
153
+ return best;
154
+ }
155
+ /**
156
+ * Return freelist pages to the OS after a cleanup pass.
157
+ *
158
+ * Databases created with `auto_vacuum=INCREMENTAL` are trimmed in bounded
159
+ * chunks so each synchronous step stays short. Legacy databases (created
160
+ * before the pragma existed) can't be trimmed incrementally, so when dead
161
+ * pages exceed ~30% of the file a one-time VACUUM rewrites it — which also
162
+ * converts it to incremental mode, since `auto_vacuum=INCREMENTAL` is set
163
+ * on this connection.
164
+ */
165
+ async function reclaimFreeSpace(db) {
166
+ const mode = await pragmaNumber(db, 'PRAGMA auto_vacuum');
167
+ if (mode === 2) {
168
+ // Chunks of ~2000 pages (~8 MB at the 4 KB default) keep each synchronous
169
+ // call short; guard bounds one pass at ~8 GB in case freelist_count
170
+ // misbehaves.
171
+ let guard = 1000;
172
+ while (guard-- > 0) {
173
+ const freelist = await pragmaNumber(db, 'PRAGMA freelist_count');
174
+ if (freelist <= 0)
175
+ break;
176
+ if (!(await incrementalVacuumChunk(db)))
177
+ break;
178
+ await yieldToEventLoop();
179
+ }
180
+ return;
181
+ }
182
+ const pageCount = await pragmaNumber(db, 'PRAGMA page_count');
183
+ const freelistCount = await pragmaNumber(db, 'PRAGMA freelist_count');
184
+ if (pageCount > 0 && freelistCount / pageCount > 0.3) {
185
+ const { log } = await import('../utils/logger.js');
186
+ log.info(`dashboard: reclaiming ${freelistCount} of ${pageCount} pages via one-time VACUUM (may pause briefly)`);
187
+ await db.raw('VACUUM');
188
+ log.info('dashboard: VACUUM complete — database converted to incremental auto-vacuum');
189
+ }
190
+ }
191
+ /**
192
+ * Free up to 2000 freelist pages. incremental_vacuum frees ONE page per
193
+ * sqlite3_step, and knex's raw() steps no-result pragmas exactly once — so
194
+ * it must run on the underlying better-sqlite3 handle, whose .pragma()
195
+ * steps to completion. Returns false if the handle has no pragma method.
196
+ */
197
+ async function incrementalVacuumChunk(db) {
198
+ const client = db.client;
199
+ const conn = (await client.acquireConnection());
200
+ try {
201
+ if (typeof conn.pragma !== 'function')
202
+ return false;
203
+ conn.pragma('incremental_vacuum(2000)');
204
+ return true;
205
+ }
206
+ finally {
207
+ client.releaseConnection(conn);
208
+ }
209
+ }
210
+ /** Run a single-row/single-column statement and return its value as a number. */
211
+ async function pragmaNumber(db, statement) {
212
+ const rows = (await db.raw(statement));
213
+ const value = Object.values(rows?.[0] ?? {})[0];
214
+ return Number(value ?? 0);
215
+ }
@@ -72,6 +72,10 @@ export async function migrateEvents(db) {
72
72
  `);
73
73
  await db.raw(`CREATE INDEX IF NOT EXISTS idx_ss_events_created ON server_stats_events(created_at)`);
74
74
  await db.raw(`CREATE INDEX IF NOT EXISTS idx_ss_events_name ON server_stats_events(event_name)`);
75
+ // Without this index every cascaded delete from server_stats_requests
76
+ // full-scans the events table (synchronously, on the host event loop) —
77
+ // the other FK children (queries, traces, logs) all have one.
78
+ await db.raw(`CREATE INDEX IF NOT EXISTS idx_ss_events_request ON server_stats_events(request_id)`);
75
79
  }
76
80
  export async function migrateEmails(db) {
77
81
  await db.raw(`
@@ -20,18 +20,23 @@ export interface StorageStatsResult {
20
20
  fileSizeMb: number;
21
21
  walSizeMb: number;
22
22
  retentionDays: number;
23
+ /** Configured live-data size cap in MB; `0` means the cap is disabled. */
24
+ maxDbSizeMb: number;
23
25
  tables: Array<{
24
26
  name: string;
25
27
  rowCount: number;
26
28
  }>;
27
29
  lastCleanupAt: number | null;
28
30
  }
31
+ /** The not-ready result returned before the SQLite connection exists. */
32
+ export declare function emptyStorageStats(dbPath: string, retentionDays: number, maxDbSizeMb: number): StorageStatsResult;
29
33
  export interface StorageStatsOpts {
30
34
  db: Knex;
31
35
  cache: CoalesceCache;
32
36
  dbFilePath: string;
33
37
  dbPath: string;
34
38
  retentionDays: number;
39
+ maxDbSizeMb: number;
35
40
  lastCleanupAt: number | null;
36
41
  onResult: (stats: StorageStatsResult) => void;
37
42
  }
@@ -58,11 +58,24 @@ export async function countAllTables(db) {
58
58
  return result;
59
59
  });
60
60
  }
61
+ /** The not-ready result returned before the SQLite connection exists. */
62
+ export function emptyStorageStats(dbPath, retentionDays, maxDbSizeMb) {
63
+ return {
64
+ ready: false,
65
+ dbPath,
66
+ fileSizeMb: 0,
67
+ walSizeMb: 0,
68
+ retentionDays,
69
+ maxDbSizeMb,
70
+ tables: [],
71
+ lastCleanupAt: null,
72
+ };
73
+ }
61
74
  /**
62
75
  * Build storage stats by querying file sizes and table row counts.
63
76
  */
64
77
  export function fetchStorageStats(opts) {
65
- const { db, cache, dbFilePath, dbPath, retentionDays, lastCleanupAt, onResult } = opts;
78
+ const { db, cache, dbFilePath, dbPath, retentionDays, maxDbSizeMb, lastCleanupAt, onResult } = opts;
66
79
  return cache.coalesce('storageStats', async () => {
67
80
  const [fileSizeMb, walSizeMb] = await getFileSizes(dbFilePath);
68
81
  const tables = await countAllTables(db);
@@ -72,6 +85,7 @@ export function fetchStorageStats(opts) {
72
85
  fileSizeMb,
73
86
  walSizeMb,
74
87
  retentionDays,
88
+ maxDbSizeMb,
75
89
  tables,
76
90
  lastCleanupAt,
77
91
  };
@@ -284,6 +284,8 @@ export interface DevToolbarConfig {
284
284
  dashboardPath: string;
285
285
  /** Data retention period in days for historical persistence. */
286
286
  retentionDays: number;
287
+ /** Size cap in MB for the SQLite database's live data; `0` disables it. */
288
+ maxDbSizeMb: number;
287
289
  /** Path to the SQLite database file for historical persistence. */
288
290
  dbPath: string;
289
291
  /** Base path for the debug toolbar API endpoints. */
@@ -33,6 +33,8 @@ function applyDashboardConfig(dashboard, result) {
33
33
  result.dashboardPath = dashboard.path;
34
34
  if (dashboard.retentionDays !== undefined)
35
35
  result.retentionDays = dashboard.retentionDays;
36
+ if (dashboard.maxDbSizeMb !== undefined)
37
+ result.maxDbSizeMb = dashboard.maxDbSizeMb;
36
38
  }
37
39
  }
38
40
  /** Advanced config field mappings. */