@mantiq/heartbeat 0.5.22 → 0.5.23

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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/Heartbeat.ts +15 -27
  3. package/src/HeartbeatServiceProvider.ts +66 -0
  4. package/src/contracts/Entry.ts +19 -0
  5. package/src/contracts/HeartbeatConfig.ts +2 -0
  6. package/src/dashboard/DashboardController.ts +71 -12
  7. package/src/dashboard/pages/CachePage.ts +61 -5
  8. package/src/dashboard/pages/CommandDetailPage.ts +216 -0
  9. package/src/dashboard/pages/CommandsPage.ts +72 -0
  10. package/src/dashboard/pages/EventsPage.ts +59 -6
  11. package/src/dashboard/pages/ExceptionsPage.ts +37 -6
  12. package/src/dashboard/pages/JobsPage.ts +61 -5
  13. package/src/dashboard/pages/LogsPage.ts +116 -0
  14. package/src/dashboard/pages/ModelsPage.ts +112 -0
  15. package/src/dashboard/pages/NotificationsPage.ts +87 -0
  16. package/src/dashboard/pages/OverviewPage.ts +109 -45
  17. package/src/dashboard/pages/PerformancePage.ts +151 -20
  18. package/src/dashboard/pages/QueriesPage.ts +92 -8
  19. package/src/dashboard/pages/RequestDetailPage.ts +227 -3
  20. package/src/dashboard/pages/RequestsPage.ts +90 -3
  21. package/src/dashboard/pages/SchedulesPage.ts +71 -0
  22. package/src/dashboard/shared/components.ts +140 -0
  23. package/src/dashboard/shared/layout.ts +327 -108
  24. package/src/index.ts +9 -0
  25. package/src/middleware/HeartbeatMiddleware.ts +26 -9
  26. package/src/migrations/CreateHeartbeatTables.ts +14 -0
  27. package/src/models/EntryModel.ts +1 -1
  28. package/src/storage/HeartbeatStore.ts +174 -1
  29. package/src/testing/HeartbeatFake.ts +6 -0
  30. package/src/tracing/Tracer.ts +32 -0
  31. package/src/watchers/CommandWatcher.ts +23 -0
  32. package/src/watchers/EventWatcher.ts +13 -0
  33. package/src/watchers/ScheduleWatcher.ts +1 -0
  34. package/src/widget/DebugWidget.ts +53 -30
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantiq/heartbeat",
3
- "version": "0.5.22",
3
+ "version": "0.5.23",
4
4
  "description": "Observability, APM & queue monitoring for MantiqJS",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/Heartbeat.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { HeartbeatConfig } from './contracts/HeartbeatConfig.ts'
2
- import type { EntryType, PendingEntry } from './contracts/Entry.ts'
2
+ import type { EntryType, PendingEntry, OriginType } from './contracts/Entry.ts'
3
3
  import type { Watcher } from './contracts/Watcher.ts'
4
4
  import { HeartbeatStore } from './storage/HeartbeatStore.ts'
5
5
  import { RecordHeartbeatEntries } from './jobs/RecordHeartbeatEntries.ts'
@@ -52,6 +52,11 @@ export class Heartbeat {
52
52
  return this.watchers
53
53
  }
54
54
 
55
+ /** Count buffered entries of a given type (for widget stats before flush). */
56
+ getBufferedCount(type: EntryType): number {
57
+ return this.buffer.filter((e) => e.type === type).length
58
+ }
59
+
55
60
  // ── Entry Recording ─────────────────────────────────────────────────────
56
61
 
57
62
  /**
@@ -69,6 +74,8 @@ export class Heartbeat {
69
74
  content,
70
75
  tags,
71
76
  requestId: this._tracer?.currentRequestId() ?? null,
77
+ originType: this._tracer?.currentOriginType() ?? 'standalone',
78
+ originId: this._tracer?.currentRequestId() ?? null,
72
79
  createdAt: Date.now(),
73
80
  }
74
81
 
@@ -94,33 +101,14 @@ export class Heartbeat {
94
101
  this.flushTimer = null
95
102
  }
96
103
 
97
- // Check if queue system is available if not, write directly (async, fire-and-forget)
98
- let queueAvailable = false
99
- try {
100
- const { getQueueManager } = require('@mantiq/queue')
101
- getQueueManager() // throws if not initialized
102
- queueAvailable = true
103
- } catch {
104
- // Queue not available
105
- }
106
-
107
- if (queueAvailable) {
108
- try {
109
- const { dispatch } = require('@mantiq/queue')
110
- dispatch(new RecordHeartbeatEntries(entries))
111
- .onQueue(this.config.queue.queue)
112
- .onConnection(this.config.queue.connection)
113
- .send()
114
- .catch(() => {
115
- this.store.insertEntries(entries).catch(() => { /* swallow */ })
116
- })
117
- } catch {
118
- this.store.insertEntries(entries).catch(() => { /* swallow */ })
104
+ // Write directly to the dedicated heartbeat SQLite database.
105
+ // The queue adds unnecessary latency for telemetry — heartbeat has its
106
+ // own isolated connection so writes don't block the main app database.
107
+ this.store.insertEntries(entries).catch((e) => {
108
+ if (process.env.APP_DEBUG === 'true') {
109
+ console.error('[Heartbeat] Failed to persist entries:', (e as Error)?.message ?? e)
119
110
  }
120
- } else {
121
- // Direct write — async fire-and-forget fallback
122
- this.store.insertEntries(entries).catch(() => { /* swallow */ })
123
- }
111
+ })
124
112
  }
125
113
 
126
114
  // ── Pruning ─────────────────────────────────────────────────────────────
@@ -21,6 +21,7 @@ import { ModelWatcher } from './watchers/ModelWatcher.ts'
21
21
  import { LogWatcher } from './watchers/LogWatcher.ts'
22
22
  import { MailWatcher } from './watchers/MailWatcher.ts'
23
23
  import { ScheduleWatcher } from './watchers/ScheduleWatcher.ts'
24
+ import { CommandWatcher } from './watchers/CommandWatcher.ts'
24
25
  import { CreateHeartbeatTables } from './migrations/CreateHeartbeatTables.ts'
25
26
  import { DashboardController } from './dashboard/DashboardController.ts'
26
27
  import { HeartbeatMiddleware } from './middleware/HeartbeatMiddleware.ts'
@@ -114,6 +115,9 @@ export class HeartbeatServiceProvider extends ServiceProvider {
114
115
  this.registerMiddleware(heartbeat, tracer, requestWatcher, metrics)
115
116
  } catch { /* HttpKernel not available */ }
116
117
 
118
+ // Wire external package hooks (CLI, logging, mail)
119
+ this.wireExternalWatchers(heartbeat, tracer)
120
+
117
121
  // Register dashboard routes
118
122
  if (config.dashboard.enabled) {
119
123
  try {
@@ -220,6 +224,7 @@ export class HeartbeatServiceProvider extends ServiceProvider {
220
224
  ['log', new LogWatcher()],
221
225
  ['mail', new MailWatcher()],
222
226
  ['schedule', new ScheduleWatcher()],
227
+ ['command', new CommandWatcher()],
223
228
  ]
224
229
 
225
230
  const on = eventBus.on.bind(eventBus)
@@ -240,4 +245,65 @@ export class HeartbeatServiceProvider extends ServiceProvider {
240
245
 
241
246
  return requestWatcher
242
247
  }
248
+
249
+ /**
250
+ * Wire the CLI command kernel to record command executions.
251
+ * Sets a static hook on @mantiq/cli Kernel so every command run
252
+ * creates a trace context and records a 'command' entry.
253
+ */
254
+ private wireExternalWatchers(heartbeat: Heartbeat, tracer: Tracer): void {
255
+ // ── CLI commands ──────────────────────────────────────────────────────
256
+ try {
257
+ const { Kernel } = require('@mantiq/cli')
258
+ const commandWatcher = heartbeat.getWatchers().find((w) => w instanceof CommandWatcher) as CommandWatcher | undefined
259
+ if (commandWatcher) {
260
+ Kernel._onCommandExecuted = (data: { name: string; args: Record<string, any>; exitCode: number; duration: number }) => {
261
+ const commandId = crypto.randomUUID()
262
+ tracer.startCommand(commandId)
263
+ commandWatcher.recordCommand({
264
+ name: data.name,
265
+ arguments: data.args,
266
+ options: {},
267
+ exit_code: data.exitCode,
268
+ duration: data.duration,
269
+ output: null,
270
+ })
271
+ heartbeat.flush()
272
+ tracer.endCommand()
273
+ }
274
+ }
275
+ } catch { /* @mantiq/cli not installed */ }
276
+
277
+ // ── Logging ───────────────────────────────────────────────────────────
278
+ try {
279
+ const { LogManager } = require('@mantiq/logging')
280
+ const logWatcher = heartbeat.getWatchers().find((w) => w instanceof LogWatcher) as LogWatcher | undefined
281
+ if (logWatcher) {
282
+ LogManager._onLog = (level: string, message: string, context: Record<string, any>, channel: string) => {
283
+ logWatcher.recordLog(level, message, context, channel)
284
+ }
285
+ }
286
+ } catch { /* @mantiq/logging not installed */ }
287
+
288
+ // ── Mail ──────────────────────────────────────────────────────────────
289
+ try {
290
+ const { MailManager } = require('@mantiq/mail')
291
+ const mailWatcher = heartbeat.getWatchers().find((w) => w instanceof MailWatcher) as MailWatcher | undefined
292
+ if (mailWatcher) {
293
+ MailManager._onMailSent = (data: { to: string[]; subject: string; mailer: string; duration: number; queued: boolean }) => {
294
+ mailWatcher.recordMail({
295
+ to: data.to,
296
+ subject: data.subject,
297
+ from: '',
298
+ mailer: data.mailer,
299
+ html: null,
300
+ text: null,
301
+ attachments: [],
302
+ duration: data.duration,
303
+ queued: data.queued,
304
+ })
305
+ }
306
+ }
307
+ } catch { /* @mantiq/mail not installed */ }
308
+ }
243
309
  }
@@ -12,6 +12,9 @@ export type EntryType =
12
12
  | 'log'
13
13
  | 'schedule'
14
14
  | 'mail'
15
+ | 'command'
16
+
17
+ export type OriginType = 'request' | 'command' | 'schedule' | 'job' | 'standalone'
15
18
 
16
19
  /**
17
20
  * A raw pending entry before it's persisted — pushed by watchers into the buffer.
@@ -21,6 +24,8 @@ export interface PendingEntry {
21
24
  content: Record<string, any>
22
25
  tags?: string[] | undefined
23
26
  requestId: string | null
27
+ originType: OriginType
28
+ originId: string | null
24
29
  createdAt: number
25
30
  }
26
31
 
@@ -32,6 +37,8 @@ export interface HeartbeatEntry {
32
37
  uuid: string
33
38
  type: EntryType
34
39
  request_id: string | null
40
+ origin_type: string
41
+ origin_id: string | null
35
42
  content: string
36
43
  tags: string
37
44
  created_at: number
@@ -103,6 +110,8 @@ export interface JobEntryContent {
103
110
  export interface EventEntryContent {
104
111
  event_class: string
105
112
  listeners_count: number
113
+ payload: Record<string, any> | null
114
+ listeners: string[]
106
115
  }
107
116
 
108
117
  export interface ModelEntryContent {
@@ -124,6 +133,16 @@ export interface ScheduleEntryContent {
124
133
  expression: string
125
134
  duration: number
126
135
  status: 'success' | 'error'
136
+ output: string | null
137
+ }
138
+
139
+ export interface CommandEntryContent {
140
+ name: string
141
+ arguments: Record<string, any>
142
+ options: Record<string, any>
143
+ exit_code: number
144
+ duration: number
145
+ output: string | null
127
146
  }
128
147
 
129
148
  export interface MailEntryContent {
@@ -24,6 +24,7 @@ export interface HeartbeatConfig {
24
24
  log: { enabled: boolean; level: string }
25
25
  schedule: { enabled: boolean }
26
26
  mail: { enabled: boolean }
27
+ command: { enabled: boolean }
27
28
  }
28
29
  tracing: { enabled: boolean; propagate: boolean }
29
30
  sampling: { rate: number; always_sample_errors: boolean }
@@ -57,6 +58,7 @@ export const DEFAULT_CONFIG: HeartbeatConfig = {
57
58
  log: { enabled: true, level: 'debug' },
58
59
  schedule: { enabled: true },
59
60
  mail: { enabled: true },
61
+ command: { enabled: true },
60
62
  },
61
63
  tracing: { enabled: true, propagate: true },
62
64
  sampling: { rate: 1.0, always_sample_errors: true },
@@ -11,6 +11,12 @@ import { renderPerformancePage } from './pages/PerformancePage.ts'
11
11
  import { renderRequestDetailPage } from './pages/RequestDetailPage.ts'
12
12
  import { renderMailPage } from './pages/MailPage.ts'
13
13
  import { renderMailDetailPage } from './pages/MailDetailPage.ts'
14
+ import { renderLogsPage } from './pages/LogsPage.ts'
15
+ import { renderModelsPage } from './pages/ModelsPage.ts'
16
+ import { renderSchedulesPage } from './pages/SchedulesPage.ts'
17
+ import { renderCommandsPage } from './pages/CommandsPage.ts'
18
+ import { renderCommandDetailPage } from './pages/CommandDetailPage.ts'
19
+ import { renderNotificationsPage } from './pages/NotificationsPage.ts'
14
20
  import type { EntryType } from '../contracts/Entry.ts'
15
21
 
16
22
  /**
@@ -40,39 +46,49 @@ export class DashboardController {
40
46
 
41
47
  // API endpoints
42
48
  if (sub.startsWith('api/')) {
43
- return this.handleApi(sub.slice(4), url.searchParams)
49
+ return this.handleApi(sub.slice(4), url.searchParams, request.method)
44
50
  }
45
51
 
46
52
  // Page routes
47
- const html = await this.renderPage(sub)
53
+ const html = await this.renderPage(sub, url.searchParams)
48
54
  return new Response(html, {
49
55
  status: 200,
50
56
  headers: { 'Content-Type': 'text/html; charset=utf-8' },
51
57
  })
52
58
  }
53
59
 
54
- private async renderPage(sub: string): Promise<string> {
60
+ private async renderPage(sub: string, searchParams?: URLSearchParams): Promise<string> {
55
61
  // Static page routes
56
62
  switch (sub) {
57
63
  case '':
58
64
  case 'overview':
59
65
  return renderOverviewPage(this.store, this.metrics, this.basePath)
60
66
  case 'requests':
61
- return renderRequestsPage(this.store, this.basePath)
67
+ return renderRequestsPage(this.store, this.basePath, searchParams)
62
68
  case 'queries':
63
- return renderQueriesPage(this.store, this.basePath)
69
+ return renderQueriesPage(this.store, this.basePath, searchParams)
64
70
  case 'exceptions':
65
- return renderExceptionsPage(this.store, this.basePath)
71
+ return renderExceptionsPage(this.store, this.basePath, searchParams)
66
72
  case 'jobs':
67
- return renderJobsPage(this.store, this.basePath)
73
+ return renderJobsPage(this.store, this.basePath, searchParams)
68
74
  case 'cache':
69
- return renderCachePage(this.store, this.basePath)
75
+ return renderCachePage(this.store, this.basePath, searchParams)
70
76
  case 'events':
71
- return renderEventsPage(this.store, this.basePath)
77
+ return renderEventsPage(this.store, this.basePath, searchParams)
72
78
  case 'performance':
73
- return renderPerformancePage(this.metrics, this.basePath)
79
+ return renderPerformancePage(this.store, this.metrics, this.basePath, searchParams)
74
80
  case 'mail':
75
81
  return renderMailPage(this.store, this.basePath)
82
+ case 'logs':
83
+ return renderLogsPage(this.store, this.basePath, searchParams)
84
+ case 'models':
85
+ return renderModelsPage(this.store, this.basePath, searchParams)
86
+ case 'schedules':
87
+ return renderSchedulesPage(this.store, this.basePath, searchParams)
88
+ case 'commands':
89
+ return renderCommandsPage(this.store, this.basePath, searchParams)
90
+ case 'notifications':
91
+ return renderNotificationsPage(this.store, this.basePath, searchParams)
76
92
  }
77
93
 
78
94
  // Parameterized routes
@@ -88,12 +104,18 @@ export class DashboardController {
88
104
  return html ?? this.render404()
89
105
  }
90
106
 
107
+ const commandDetail = sub.match(/^commands\/([a-f0-9-]+)$/)
108
+ if (commandDetail) {
109
+ const html = await renderCommandDetailPage(this.store, commandDetail[1]!, this.basePath)
110
+ return html ?? this.render404()
111
+ }
112
+
91
113
  return this.render404()
92
114
  }
93
115
 
94
116
  // ── API Endpoints ─────────────────────────────────────────────────────
95
117
 
96
- private async handleApi(sub: string, params: URLSearchParams): Promise<Response> {
118
+ private async handleApi(sub: string, params: URLSearchParams, method: string): Promise<Response> {
97
119
  try {
98
120
  switch (sub) {
99
121
  case 'entries':
@@ -102,8 +124,31 @@ export class DashboardController {
102
124
  return this.apiMetrics()
103
125
  case 'exception-groups':
104
126
  return this.apiExceptionGroups()
105
- default:
127
+ case 'exception-groups/resolve':
128
+ if (method === 'POST') return this.apiResolveExceptionGroup(params)
129
+ return Response.json({ error: 'Method not allowed' }, { status: 405 })
130
+ case 'exception-groups/unresolve':
131
+ if (method === 'POST') return this.apiUnresolveExceptionGroup(params)
132
+ return Response.json({ error: 'Method not allowed' }, { status: 405 })
133
+ default: {
134
+ // Handle parameterized API routes: exceptions/{fingerprint}/resolve|unresolve
135
+ const exceptionAction = sub.match(/^exceptions\/([^/]+)\/(resolve|unresolve)$/)
136
+ if (exceptionAction) {
137
+ const fingerprint = decodeURIComponent(exceptionAction[1]!)
138
+ const action = exceptionAction[2]!
139
+ if (action === 'resolve') {
140
+ await this.store.resolveExceptionGroup(fingerprint)
141
+ } else {
142
+ await this.store.unresolveExceptionGroup(fingerprint)
143
+ }
144
+ // Redirect back to exceptions page
145
+ return new Response(null, {
146
+ status: 302,
147
+ headers: { Location: `${this.basePath}/exceptions` },
148
+ })
149
+ }
106
150
  return Response.json({ error: 'Not found' }, { status: 404 })
151
+ }
107
152
  }
108
153
  } catch (error) {
109
154
  return Response.json({ error: 'Internal error' }, { status: 500 })
@@ -161,6 +206,20 @@ export class DashboardController {
161
206
  return Response.json({ data: groups })
162
207
  }
163
208
 
209
+ private async apiResolveExceptionGroup(params: URLSearchParams): Promise<Response> {
210
+ const fingerprint = params.get('fingerprint')
211
+ if (!fingerprint) return Response.json({ error: 'Missing fingerprint' }, { status: 400 })
212
+ await this.store.resolveExceptionGroup(fingerprint)
213
+ return Response.json({ success: true })
214
+ }
215
+
216
+ private async apiUnresolveExceptionGroup(params: URLSearchParams): Promise<Response> {
217
+ const fingerprint = params.get('fingerprint')
218
+ if (!fingerprint) return Response.json({ error: 'Missing fingerprint' }, { status: 400 })
219
+ await this.store.unresolveExceptionGroup(fingerprint)
220
+ return Response.json({ success: true })
221
+ }
222
+
164
223
  private render404(): string {
165
224
  return `<!DOCTYPE html><html><body><h1>404 — Page not found</h1><p><a href="${this.basePath}">Back to Heartbeat</a></p></body></html>`
166
225
  }
@@ -1,13 +1,31 @@
1
1
  import { renderLayout } from '../shared/layout.ts'
2
- import { table, badge, timeAgo, escapeHtml, truncate, stat } from '../shared/components.ts'
2
+ import { table, badge, timeAgo, escapeHtml, truncate, stat, filterBar, pagination } from '../shared/components.ts'
3
3
  import type { HeartbeatStore } from '../../storage/HeartbeatStore.ts'
4
4
  import type { CacheEntryContent } from '../../contracts/Entry.ts'
5
5
 
6
- export async function renderCachePage(store: HeartbeatStore, basePath: string): Promise<string> {
7
- const entries = await store.getEntries({ type: 'cache', limit: 100 })
6
+ const PER_PAGE = 50
8
7
 
8
+ export async function renderCachePage(store: HeartbeatStore, basePath: string, searchParams?: URLSearchParams): Promise<string> {
9
+ const page = parseInt(searchParams?.get('page') ?? '1')
10
+ const operationFilter = searchParams?.get('operation') ?? ''
11
+ const keySearch = searchParams?.get('search') ?? ''
12
+
13
+ // Fetch all cache entries for stats + filtering
14
+ const allEntries = await store.getEntries({ type: 'cache', limit: 5000 })
15
+
16
+ // Apply filters in-memory
17
+ const filtered = allEntries.filter((e) => {
18
+ const c = JSON.parse(e.content) as CacheEntryContent
19
+ if (operationFilter && c.operation !== operationFilter) return false
20
+ if (keySearch && !c.key.toLowerCase().includes(keySearch.toLowerCase())) return false
21
+ return true
22
+ })
23
+
24
+ const total = filtered.length
25
+
26
+ // Compute stats from all entries (not just filtered) for overall hit rate
9
27
  let hits = 0, misses = 0, writes = 0, forgets = 0
10
- for (const e of entries) {
28
+ for (const e of allEntries) {
11
29
  const op = (JSON.parse(e.content) as CacheEntryContent).operation
12
30
  if (op === 'hit') hits++
13
31
  else if (op === 'miss') misses++
@@ -17,7 +35,10 @@ export async function renderCachePage(store: HeartbeatStore, basePath: string):
17
35
 
18
36
  const hitRate = hits + misses > 0 ? ((hits / (hits + misses)) * 100).toFixed(0) + '%' : '--'
19
37
 
20
- const rows = entries.map((entry) => {
38
+ // Paginate filtered entries
39
+ const pageEntries = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE)
40
+
41
+ const rows = pageEntries.map((entry) => {
21
42
  const c = JSON.parse(entry.content) as CacheEntryContent
22
43
  const v = c.operation === 'hit' ? 'green' : c.operation === 'miss' ? 'amber' : c.operation === 'write' ? 'blue' : 'mute'
23
44
  return [
@@ -28,7 +49,34 @@ export async function renderCachePage(store: HeartbeatStore, basePath: string):
28
49
  ]
29
50
  })
30
51
 
52
+ // Build extra params for pagination
53
+ const extraParams: Record<string, string> = {}
54
+ if (operationFilter) extraParams['operation'] = operationFilter
55
+ if (keySearch) extraParams['search'] = keySearch
56
+
57
+ const filters = filterBar({
58
+ action: `${basePath}/cache`,
59
+ searchPlaceholder: 'Search key...',
60
+ searchValue: keySearch,
61
+ filters: [
62
+ {
63
+ name: 'operation',
64
+ label: 'Operation',
65
+ options: [
66
+ { value: 'hit', label: 'Hit' },
67
+ { value: 'miss', label: 'Miss' },
68
+ { value: 'write', label: 'Write' },
69
+ { value: 'forget', label: 'Forget' },
70
+ ],
71
+ selected: operationFilter,
72
+ },
73
+ ],
74
+ })
75
+
76
+ const paginationBase = buildPaginationUrl(`${basePath}/cache`, extraParams)
77
+
31
78
  const content = `
79
+ ${filters}
32
80
  <div class="stats">
33
81
  ${stat('Hit Rate', hitRate, `${hits} hits / ${misses} misses`)}
34
82
  ${stat('Hits', hits.toString())}
@@ -38,7 +86,15 @@ export async function renderCachePage(store: HeartbeatStore, basePath: string):
38
86
  <div class="card">
39
87
  ${table(['Operation', 'Key', 'Store', 'Time'], rows)}
40
88
  </div>
89
+ ${pagination(total, page, PER_PAGE, paginationBase)}
41
90
  `
42
91
 
43
92
  return renderLayout({ title: 'Cache', activePage: 'cache', basePath, content })
44
93
  }
94
+
95
+ function buildPaginationUrl(base: string, params: Record<string, string>): string {
96
+ const entries = Object.entries(params).filter(([, v]) => v)
97
+ if (entries.length === 0) return base
98
+ const qs = entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
99
+ return `${base}?${qs}`
100
+ }