@stacksjs/defaults 0.75.482 → 0.75.483

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 (54) hide show
  1. package/ai/skills/stacks-actions/SKILL.md +1 -1
  2. package/ai/skills/stacks-native/CATALOG.md +4 -4
  3. package/ai/skills/stacks-queue/DURABLE-WORKFLOWS.md +12 -0
  4. package/app/Actions/Dashboard/Content/CommentIndexAction.ts +22 -112
  5. package/app/Actions/Dashboard/Content/PageIndexAction.ts +26 -25
  6. package/app/Actions/Dashboard/Content/PostIndexAction.ts +30 -35
  7. package/app/Actions/Dashboard/Content/PostShowAction.ts +41 -0
  8. package/app/Actions/Dashboard/Content/comment-input.ts +25 -1
  9. package/app/Actions/Dashboard/Content/comment-list.ts +311 -0
  10. package/app/Actions/Dashboard/Content/content-list.ts +69 -0
  11. package/app/Actions/Dashboard/Content/page-list.ts +108 -0
  12. package/app/Actions/Dashboard/Content/post-list.ts +140 -0
  13. package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +12 -24
  14. package/app/Actions/Dashboard/Jobs/job-list.ts +168 -0
  15. package/app/Actions/Dashboard/Jobs/job-records.ts +8 -3
  16. package/app/Actions/Dashboard/Models/ModelShowAction.ts +8 -2
  17. package/app/Actions/Dashboard/Queries/QueryDashboardAction.ts +20 -10
  18. package/app/Actions/Dashboard/Queries/query-dashboard.ts +56 -9
  19. package/app/Actions/Dashboard/Queries/query-list.ts +161 -0
  20. package/app/Actions/Dashboard/Queue/QueueStatsAction.ts +14 -52
  21. package/app/Actions/Dashboard/Queue/queue-stats.ts +69 -0
  22. package/app/Actions/Monitoring/ErrorIndexAction.ts +9 -4
  23. package/app/Actions/Monitoring/error-provider.ts +85 -7
  24. package/functions/api.ts +14 -10
  25. package/functions/commerce/coupons.ts +4 -6
  26. package/functions/commerce/customers.ts +4 -6
  27. package/functions/commerce/gift-cards.ts +4 -2
  28. package/functions/commerce/orders.ts +4 -2
  29. package/functions/commerce/payments.ts +4 -2
  30. package/functions/commerce/products/categories.ts +4 -6
  31. package/functions/commerce/products/manufacturers.ts +4 -6
  32. package/functions/commerce/products/products.ts +4 -6
  33. package/functions/commerce/products/reviews.ts +4 -6
  34. package/functions/commerce/products/units.ts +4 -6
  35. package/functions/commerce/products/variants.ts +4 -6
  36. package/functions/commerce/taxes.ts +4 -2
  37. package/functions/commerce/waitlist/products.ts +4 -6
  38. package/functions/commerce/waitlist/restaurants.ts +4 -6
  39. package/functions/monitoring/errors.ts +77 -49
  40. package/functions/queries.ts +34 -2
  41. package/ide/vscode/package.json +1 -1
  42. package/package.json +2 -2
  43. package/resources/components/Dashboard/Analytics/AnalyticsHub.stx +4 -0
  44. package/resources/components/Dashboard/Auth/MagicLink.stx +6 -1
  45. package/resources/components/Dashboard/Content/CommentsDashboard.stx +90 -12
  46. package/resources/components/Dashboard/Content/PagesDashboard.stx +100 -16
  47. package/resources/components/Dashboard/Content/PostEditorDialog.stx +28 -8
  48. package/resources/components/Dashboard/Content/PostsDashboard.stx +179 -21
  49. package/resources/components/Dashboard/Monitoring/ErrorDashboard.stx +121 -36
  50. package/resources/components/Dashboard/Monitoring/ErrorTable.stx +29 -8
  51. package/resources/components/Dashboard/Queries/QueryDashboard.stx +73 -64
  52. package/resources/components/Dashboard/UI/Table.stx +30 -7
  53. package/routes/dashboard-api.ts +1 -0
  54. package/app/Actions/Buddy/JobsListAction.ts +0 -141
@@ -0,0 +1,168 @@
1
+ import { db } from '@stacksjs/database/runtime'
2
+ import type { DashboardJobStatus, ModelRecord, NormalizedJob } from './job-records'
3
+ import { normalizeActiveJob, normalizeFailedJob, parseJobReference } from './job-records'
4
+
5
+ /**
6
+ * One page of the Jobs list, read by the database (stacksjs/stacks#3201).
7
+ *
8
+ * Jobs live in two tables: `jobs`, waiting or reserved, and `failed_jobs`.
9
+ * The list used to read both whole and page them in memory. Now each is
10
+ * filtered and counted in SQL. A page is newest first across both: for page
11
+ * p of n, each table gives its newest p * n matches, which must hold the
12
+ * page, and the two are merged and sliced. Deep pages read more, never more
13
+ * than the matching rows.
14
+ *
15
+ * Through `db` rather than the models: a model query's `whereLike` is a plain
16
+ * LIKE, which is case-sensitive on PostgreSQL, while `db` compares both sides
17
+ * lowercased on every dialect.
18
+ */
19
+
20
+ export interface JobListQuery {
21
+ page: number
22
+ perPage: number
23
+ /** An exact queue, or empty for every queue. */
24
+ queue: string
25
+ status: DashboardJobStatus | ''
26
+ search: string
27
+ }
28
+
29
+ export interface JobListPage {
30
+ data: NormalizedJob[]
31
+ total: number
32
+ page: number
33
+ /** Every queue with a job or a failure, for the queue filter. */
34
+ queues: string[]
35
+ }
36
+
37
+ const STATES: DashboardJobStatus[] = ['queued', 'processing', 'failed']
38
+
39
+ /** The states whose name holds the search, which the list always searched. */
40
+ function statesNamed(term: string): Set<DashboardJobStatus> {
41
+ return new Set(term ? STATES.filter(state => state.includes(term)) : [])
42
+ }
43
+
44
+ /** An id the search names: `12`, or a reference such as `job-12`. */
45
+ function idNamed(term: string, source: 'job' | 'failed'): number | null {
46
+ const reference = parseJobReference(term)
47
+ const id = reference.source === null || reference.source === source ? reference.id : ''
48
+ return /^\d+$/.test(id) ? Number(id) : null
49
+ }
50
+
51
+ /**
52
+ * A job is reserved when `reserved_at` is set and not 0, `queuedJobState`'s
53
+ * rule in `@stacksjs/queue`. The queue writes NULL or a timestamp, never 0.
54
+ */
55
+ function activeQuery(query: JobListQuery): any | null {
56
+ if (query.status === 'failed')
57
+ return null
58
+
59
+ let chain: any = db.selectFrom('jobs')
60
+ if (query.queue)
61
+ chain = chain.where('queue', '=', query.queue)
62
+ if (query.status === 'processing')
63
+ chain = chain.whereNotNull('reserved_at').where('reserved_at', '!=', 0)
64
+ else if (query.status === 'queued')
65
+ chain = chain.whereGroup((group: any) => group.whereNull('reserved_at').orWhere('reserved_at', '=', 0))
66
+
67
+ const term = query.search.toLowerCase()
68
+ if (term) {
69
+ const states = statesNamed(term)
70
+ // Every waiting job's reference is `job-<id>`, so a search inside that
71
+ // word names them all, as it did when the reference itself was searched.
72
+ if ((states.has('queued') && states.has('processing')) || 'job-'.includes(term))
73
+ return chain
74
+ const id = idNamed(term, 'job')
75
+ chain = chain.whereGroup((group: any) => {
76
+ let match = group.whereLike('queue', `%${term}%`).orWhereLike('payload', `%${term}%`)
77
+ if (id !== null)
78
+ match = match.orWhere('id', '=', id)
79
+ if (states.has('queued'))
80
+ match = match.orWhereNull('reserved_at').orWhere('reserved_at', '=', 0)
81
+ if (states.has('processing'))
82
+ match = match.orWhere('reserved_at', '!=', 0)
83
+ return match
84
+ })
85
+ }
86
+ return chain
87
+ }
88
+
89
+ function failedQuery(query: JobListQuery): any | null {
90
+ if (query.status === 'queued' || query.status === 'processing')
91
+ return null
92
+
93
+ let chain: any = db.selectFrom('failed_jobs')
94
+ if (query.queue)
95
+ chain = chain.where('queue', '=', query.queue)
96
+
97
+ const term = query.search.toLowerCase()
98
+ if (term && !statesNamed(term).has('failed') && !'failed-'.includes(term)) {
99
+ const id = idNamed(term, 'failed')
100
+ chain = chain.whereGroup((group: any) => {
101
+ let match = group.whereLike('queue', `%${term}%`)
102
+ .orWhereLike('payload', `%${term}%`)
103
+ .orWhereLike('exception', `%${term}%`)
104
+ .orWhereLike('connection', `%${term}%`)
105
+ if (id !== null)
106
+ match = match.orWhere('id', '=', id)
107
+ return match
108
+ })
109
+ }
110
+ return chain
111
+ }
112
+
113
+ /** A row as the normalizers read a model record. */
114
+ function asRecord(row: Record<string, unknown>): ModelRecord {
115
+ return { get: (key: string) => row[key] } as unknown as ModelRecord
116
+ }
117
+
118
+ async function countOf(chain: any | null): Promise<number> {
119
+ if (!chain)
120
+ return 0
121
+ const row = await chain.select(db.fn.count('id').as('count')).executeTakeFirst() as { count?: unknown } | undefined
122
+ return Number(row?.count ?? 0) || 0
123
+ }
124
+
125
+ async function newestRows(chain: any, limit: number): Promise<Record<string, unknown>[]> {
126
+ return await chain.selectAll().orderBy('created_at', 'desc').orderBy('id', 'desc').limit(limit).execute() as Record<string, unknown>[]
127
+ }
128
+
129
+ /** Newest first; at the same moment a waiting job before a failure, then the higher id, as before. */
130
+ function newestFirst(a: NormalizedJob, b: NormalizedJob): number {
131
+ const byTime = new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
132
+ if (byTime !== 0)
133
+ return byTime
134
+ if (a.source !== b.source)
135
+ return a.source === 'job' ? -1 : 1
136
+ return Number(b.recordId) - Number(a.recordId)
137
+ }
138
+
139
+ export async function readJobPage(query: JobListQuery): Promise<JobListPage> {
140
+ const active = activeQuery(query)
141
+ const failed = failedQuery(query)
142
+
143
+ const [activeTotal, failedTotal, queueRows] = await Promise.all([
144
+ countOf(active),
145
+ countOf(failed),
146
+ db.unsafe('SELECT DISTINCT queue FROM jobs UNION SELECT DISTINCT queue FROM failed_jobs').execute() as Promise<Array<{ queue: unknown }>>,
147
+ ])
148
+
149
+ const total = activeTotal + failedTotal
150
+ const page = Math.min(query.page, Math.max(1, Math.ceil(total / query.perPage)))
151
+ const limit = page * query.perPage
152
+
153
+ const [activeRows, failedRows] = await Promise.all([
154
+ activeTotal > 0 ? newestRows(activeQuery(query), limit) : Promise.resolve([]),
155
+ failedTotal > 0 ? newestRows(failedQuery(query), limit) : Promise.resolve([]),
156
+ ])
157
+
158
+ const data = [...activeRows.map(row => normalizeActiveJob(asRecord(row))), ...failedRows.map(row => normalizeFailedJob(asRecord(row)))]
159
+ .sort(newestFirst)
160
+ .slice((page - 1) * query.perPage, limit)
161
+
162
+ return {
163
+ data,
164
+ total,
165
+ page,
166
+ queues: [...new Set(queueRows.map(row => String(row.queue ?? '')).filter(Boolean))].sort(),
167
+ }
168
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ReadableRecord } from '@stacksjs/orm'
2
+ import { queuedJobState } from '@stacksjs/queue'
2
3
  export type JobRecordSource = 'job' | 'failed'
3
4
  export type DashboardJobStatus = 'queued' | 'processing' | 'failed'
4
5
 
@@ -154,6 +155,10 @@ export function normalizeActiveJob(record: ModelRecord): NormalizedJob {
154
155
  const payload = parsePayload(record.get('payload'))
155
156
  const queue = requiredString(record, 'queue')
156
157
  const reservedAt = timestampValue(record, 'reserved_at', 'Job', true)
158
+ // Reserved is `queuedJobState`'s rule: set and not 0. A 0 parsed as 1970
159
+ // and read as processing here while the queue and the stats called it
160
+ // waiting (stacksjs/stacks#3201).
161
+ const reserved = queuedJobState({ reserved_at: record.get('reserved_at'), available_at: record.get('available_at') }) === 'processing'
157
162
 
158
163
  return {
159
164
  id: jobReference('job', id),
@@ -162,7 +167,7 @@ export function normalizeActiveJob(record: ModelRecord): NormalizedJob {
162
167
  name: jobName(payload, queue),
163
168
  queue,
164
169
  connection: null,
165
- status: reservedAt ? 'processing' : 'queued',
170
+ status: reserved ? 'processing' : 'queued',
166
171
  attempts: integerValue(record, 'attempts'),
167
172
  maxAttempts: payloadMaxAttempts(payload),
168
173
  duration: null,
@@ -171,8 +176,8 @@ export function normalizeActiveJob(record: ModelRecord): NormalizedJob {
171
176
  created_at: timestampValue(record, 'created_at')!,
172
177
  updated_at: timestampValue(record, 'updated_at', 'Job', true),
173
178
  available_at: timestampValue(record, 'available_at', 'Job', true),
174
- reserved_at: reservedAt,
175
- started_at: reservedAt,
179
+ reserved_at: reserved ? reservedAt : undefined,
180
+ started_at: reserved ? reservedAt : undefined,
176
181
  }
177
182
  }
178
183
 
@@ -269,11 +269,17 @@ export default new Action({
269
269
  chain = chain.where(column, value)
270
270
  }
271
271
  if (q && searchColumns.length > 0) {
272
+ // Lowercased on both sides, so the search ignores case on every
273
+ // dialect. A model query's whereLike is a plain LIKE, which is
274
+ // case-sensitive on PostgreSQL: "donald" did not find "Donald"
275
+ // there (stacksjs/stacks#3202). The column is one of the model's
276
+ // own (isSafeColumn), and the term is bound.
272
277
  chain = chain.whereGroup((group: any) => {
273
278
  for (const [index, column] of searchColumns.entries()) {
279
+ const match = `LOWER(${column}) LIKE LOWER(?)`
274
280
  group = index === 0
275
- ? group.whereLike(column, `%${q}%`)
276
- : group.orWhereLike(column, `%${q}%`)
281
+ ? group.whereRaw(match, `%${q}%`)
282
+ : group.orWhereRaw(match, `%${q}%`)
277
283
  }
278
284
  return group
279
285
  })
@@ -1,29 +1,39 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions/runtime'
2
3
  import { config } from '@stacksjs/config'
3
- import { db } from '@stacksjs/database/runtime'
4
+ import { response } from '@stacksjs/router'
4
5
  import { dashboardOperationalError } from '../dashboard-response'
5
- import { dashboardQueryColumns, mapDashboardQueryLog, type QueryLogSourceRow } from './query-dashboard'
6
+ import { parseQueryListQuery, readQueryLogPage } from './query-list'
6
7
 
8
+ /**
9
+ * `GET /api/dashboard/queries`: one page of the query log for the Queries
10
+ * pages, with the counts their cards show. The range, filters and page are
11
+ * read by the database (`query-list.ts`, stacksjs/stacks#3204).
12
+ */
7
13
  export default new Action({
8
14
  name: 'Query Dashboard',
9
15
  description: 'Returns safe, native query log records for the operator dashboard.',
10
16
  method: 'GET',
11
17
  apiResponse: true,
12
18
 
13
- async handle() {
19
+ async handle(request: RequestInstance) {
20
+ const query = parseQueryListQuery(key => request.get<unknown>(key, ''))
21
+ if ('error' in query)
22
+ return response.json({ message: query.error }, 422)
23
+
14
24
  const queryLogging = config.database?.queryLogging
15
25
  try {
16
- const rows = await db
17
- .selectFrom('query_logs')
18
- .select(dashboardQueryColumns)
19
- .orderBy('executed_at', 'desc')
20
- .limit(2000)
21
- .execute()
26
+ const result = await readQueryLogPage(query)
22
27
 
23
28
  return {
24
29
  enabled: queryLogging?.enabled === true,
25
30
  slowThreshold: queryLogging?.slowThreshold || 100,
26
- queries: rows.map((row: unknown) => mapDashboardQueryLog(row as QueryLogSourceRow)),
31
+ queries: result.rows,
32
+ total: result.counts.total,
33
+ page: result.page,
34
+ perPage: query.perPage,
35
+ counts: result.counts,
36
+ connections: result.connections,
27
37
  }
28
38
  }
29
39
  catch (error) {
@@ -60,16 +60,56 @@ export interface DashboardQueryLog {
60
60
  suggestions: string[]
61
61
  }
62
62
 
63
+ export const QUERY_TYPES = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'OTHER'] as const
64
+
65
+ /** What `queryType` skips before the first word: ASCII whitespace, as SQL can strip it too. */
66
+ const LEADING_SPACE = /^[ \t\n\v\f\r]+/
67
+
68
+ /**
69
+ * The statement's kind, from its first word. `queryTypeSql` is the same rule
70
+ * in SQL, so the list can filter and count by kind in the database
71
+ * (stacksjs/stacks#3204); keep the two in step.
72
+ */
63
73
  export function queryType(query: string): DashboardQueryLog['type'] {
64
- const type = query.trim().match(/^([a-z]+)/i)?.[1]?.toUpperCase()
74
+ const type = query.replace(LEADING_SPACE, '').match(/^([a-z]+)/i)?.[1]?.toUpperCase()
65
75
  return type === 'SELECT' || type === 'INSERT' || type === 'UPDATE' || type === 'DELETE'
66
76
  ? type
67
77
  : 'OTHER'
68
78
  }
69
79
 
70
- export function parseQueryLogList(value?: string | null, label = 'query log list'): string[] {
71
- if (!value)
80
+ const ASCII_LETTERS = Array.from({ length: 26 }, (_, index) => String.fromCharCode(65 + index))
81
+ .flatMap(letter => [letter, letter.toLowerCase()])
82
+ .map(letter => `'${letter}'`)
83
+ .join(', ')
84
+
85
+ /**
86
+ * `queryType` as a SQL expression over `query_logs.query`, for one dialect.
87
+ *
88
+ * The text loses the same leading whitespace, then a kind matches when the
89
+ * text starts with its keyword in any case and the next character is not a
90
+ * letter, which is what `queryType`'s first-word match comes to. MySQL
91
+ * compares bytes here, because its default collation would also match
92
+ * accented letters.
93
+ */
94
+ export function queryTypeSql(dialect: string): string {
95
+ const space = [9, 10, 11, 12, 13]
96
+ // MySQL's LTRIM strips spaces only. Its pattern holds the whitespace itself
97
+ // in a plain literal, which takes the column's collation where CHAR() would
98
+ // bring its own and be refused.
99
+ const text = dialect === 'mysql'
100
+ ? `REGEXP_REPLACE(query, '^[ ${space.map(code => String.fromCharCode(code)).join('')}]+', '')`
101
+ : `LTRIM(query, ' ' || ${space.map(code => `${dialect === 'postgres' ? 'chr' : 'char'}(${code})`).join(' || ')})`
102
+ const exact = (value: string): string => dialect === 'mysql' ? `CAST(${value} AS BINARY)` : value
103
+ const branches = QUERY_TYPES.filter(type => type !== 'OTHER').map(type =>
104
+ `WHEN ${exact(`UPPER(SUBSTR(${text}, 1, ${type.length}))`)} = '${type}' AND ${exact(`SUBSTR(${text}, ${type.length + 1}, 1)`)} NOT IN (${ASCII_LETTERS}) THEN '${type}'`)
105
+ return `(CASE ${branches.join(' ')} ELSE 'OTHER' END)`
106
+ }
107
+
108
+ export function parseQueryLogList(value?: unknown, label = 'query log list'): string[] {
109
+ if (value == null || value === '')
72
110
  return []
111
+ if (typeof value !== 'string')
112
+ throw new TypeError(`${label} must be JSON text`)
73
113
 
74
114
  let parsed: unknown
75
115
  try {
@@ -85,8 +125,15 @@ export function parseQueryLogList(value?: string | null, label = 'query log list
85
125
  return parsed
86
126
  }
87
127
 
88
- export function mapDashboardQueryLog(row: QueryLogSourceRow): DashboardQueryLog {
128
+ export function mapDashboardQueryLog(row: QueryLogSourceRow | Record<string, unknown>): DashboardQueryLog {
89
129
  const label = `query log ${row.id}`
130
+ if (!((typeof row.id === 'number' && Number.isSafeInteger(row.id) && row.id > 0) || (typeof row.id === 'string' && row.id.trim())))
131
+ throw new TypeError('Query log id must be a positive integer or a non-empty string')
132
+ const text = (value: unknown, field: string): string => {
133
+ if (value == null) return ''
134
+ if (typeof value !== 'string') throw new TypeError(`${label} ${field} must be text or null`)
135
+ return value
136
+ }
90
137
  const status = queryStatus(row.status, label)
91
138
  if (typeof row.query !== 'string' || !row.query.trim())
92
139
  throw new TypeError(`${label} query must be a non-empty string`)
@@ -97,15 +144,15 @@ export function mapDashboardQueryLog(row: QueryLogSourceRow): DashboardQueryLog
97
144
  return {
98
145
  id: row.id,
99
146
  query: row.query,
100
- normalizedQuery: row.normalized_query || row.query,
147
+ normalizedQuery: text(row.normalized_query, 'normalized query') || row.query,
101
148
  type: queryType(row.query),
102
149
  duration: finiteNumber(row.duration, `${label} duration`),
103
- connection: row.connection || 'unknown',
150
+ connection: text(row.connection, 'connection') || 'unknown',
104
151
  status,
105
- error: row.error || '',
152
+ error: text(row.error, 'error'),
106
153
  executedAt,
107
- model: row.model || '',
108
- method: row.method || '',
154
+ model: text(row.model, 'model'),
155
+ method: text(row.method, 'method'),
109
156
  rowsAffected: nullableFiniteNumber(row.rows_affected, `${label} rows_affected`),
110
157
  memoryUsage: nullableFiniteNumber(row.memory_usage, `${label} memory_usage`),
111
158
  tags: parseQueryLogList(row.tags, `${label} tags`),
@@ -0,0 +1,161 @@
1
+ import { db, getDatabaseDialect, sqlDateTime, sqlHelpers } from '@stacksjs/database/runtime'
2
+ import type { QueryReader } from '../Content/content-list'
3
+ import { countOf, LIST_PAGE_SIZES, pageWithin, queryText } from '../Content/content-list'
4
+ import type { DashboardQueryLog } from './query-dashboard'
5
+ import { dashboardQueryColumns, mapDashboardQueryLog, QUERY_TYPES, queryTypeSql } from './query-dashboard'
6
+
7
+ /**
8
+ * One page of the query log, read by the database.
9
+ *
10
+ * The Queries pages used to fetch the newest 2,000 logs and filter, count and
11
+ * page those in the browser, so a week, or a slow query older than the
12
+ * newest 2,000, was simply not there (stacksjs/stacks#3204). Now the range,
13
+ * filters, counts and page are all SQL.
14
+ */
15
+
16
+ export const QUERY_RANGES = { hour: 1, day: 24, week: 168 } as const
17
+ export const QUERY_STATUSES = ['completed', 'slow', 'failed'] as const
18
+
19
+ export type QueryRange = keyof typeof QUERY_RANGES
20
+ export type QueryStatus = typeof QUERY_STATUSES[number]
21
+
22
+ export interface QueryListQuery {
23
+ range: QueryRange
24
+ status: QueryStatus | 'all'
25
+ type: DashboardQueryLog['type'] | 'all'
26
+ connection: string
27
+ page: number
28
+ perPage: number
29
+ }
30
+
31
+ export interface QueryCounts {
32
+ total: number
33
+ reads: number
34
+ writes: number
35
+ slow: number
36
+ failed: number
37
+ /** Mean duration in milliseconds, 0 when nothing matched. */
38
+ averageDuration: number
39
+ }
40
+
41
+ export interface QueryLogPage {
42
+ rows: DashboardQueryLog[]
43
+ /** The page the rows are, moved back to the last one when it ran past it. */
44
+ page: number
45
+ /** Counts over every log matching the range and filters; `total` is what the pager counts. */
46
+ counts: QueryCounts
47
+ /** The connections that logged anything in the range, for the connection filter. */
48
+ connections: string[]
49
+ }
50
+
51
+ /** The range, filters and page, or the reason they are invalid. Absent values take the page's defaults. */
52
+ export function parseQueryListQuery(read: QueryReader): QueryListQuery | { error: string } {
53
+ const range = queryText(read, 'range') || 'day'
54
+ if (!Object.hasOwn(QUERY_RANGES, range))
55
+ return { error: `range must be one of ${Object.keys(QUERY_RANGES).join(', ')}.` }
56
+
57
+ const status = queryText(read, 'status') || 'all'
58
+ if (status !== 'all' && !(QUERY_STATUSES as readonly string[]).includes(status))
59
+ return { error: `status must be all or one of ${QUERY_STATUSES.join(', ')}.` }
60
+
61
+ const type = queryText(read, 'type') || 'all'
62
+ if (type !== 'all' && !(QUERY_TYPES as readonly string[]).includes(type))
63
+ return { error: `type must be all or one of ${QUERY_TYPES.join(', ')}.` }
64
+
65
+ const connection = queryText(read, 'connection')
66
+ if (connection.length > 200)
67
+ return { error: 'connection must be 200 characters or fewer.' }
68
+
69
+ const pageText = queryText(read, 'page')
70
+ const page = pageText === '' ? 1 : Number(pageText)
71
+ if (!Number.isInteger(page) || page < 1)
72
+ return { error: 'page must be a whole number of 1 or more.' }
73
+
74
+ const sizeText = queryText(read, 'per_page')
75
+ const perPage = sizeText === '' ? 50 : Number(sizeText)
76
+ if (!(LIST_PAGE_SIZES as readonly number[]).includes(perPage))
77
+ return { error: `per_page must be one of ${LIST_PAGE_SIZES.join(', ')}.` }
78
+
79
+ return {
80
+ range: range as QueryRange,
81
+ status: status as QueryListQuery['status'],
82
+ type: type as QueryListQuery['type'],
83
+ connection,
84
+ page,
85
+ perPage,
86
+ }
87
+ }
88
+
89
+ /** The earliest `executed_at` in the range, in the form the query logger writes it. */
90
+ export function rangeStart(range: QueryRange, now: number = Date.now()): string {
91
+ return sqlDateTime(new Date(now - QUERY_RANGES[range] * 3_600_000))
92
+ }
93
+
94
+ /**
95
+ * The WHERE clause for a query, every value bound. The range alone is the
96
+ * connection filter's scope, so it is built separately.
97
+ */
98
+ function where(query: QueryListQuery, now: number): { range: { sql: string, values: unknown[] }, all: { sql: string, values: unknown[] } } {
99
+ const dialect = getDatabaseDialect()
100
+ const helpers = sqlHelpers(dialect)
101
+ const values: unknown[] = []
102
+ const bind = (value: unknown): string => {
103
+ values.push(value)
104
+ return helpers.param(values.length)
105
+ }
106
+
107
+ const clauses = [`executed_at >= ${bind(rangeStart(query.range, now))}`]
108
+ const range = { sql: `WHERE ${clauses[0]}`, values: [...values] }
109
+ if (query.status !== 'all')
110
+ clauses.push(`status = ${bind(query.status)}`)
111
+ if (query.type !== 'all')
112
+ clauses.push(`${queryTypeSql(dialect)} = ${bind(query.type)}`)
113
+ if (query.connection)
114
+ clauses.push(`connection = ${bind(query.connection)}`)
115
+
116
+ return { range, all: { sql: `WHERE ${clauses.join(' AND ')}`, values } }
117
+ }
118
+
119
+ export async function readQueryLogPage(query: QueryListQuery, now: number = Date.now()): Promise<QueryLogPage> {
120
+ const type = queryTypeSql(getDatabaseDialect())
121
+ const filter = where(query, now)
122
+
123
+ const [[counted], connectionRows] = await Promise.all([
124
+ db.unsafe(
125
+ `SELECT COUNT(*) AS total_count,
126
+ SUM(CASE WHEN ${type} = 'SELECT' THEN 1 ELSE 0 END) AS read_count,
127
+ SUM(CASE WHEN ${type} IN ('INSERT', 'UPDATE', 'DELETE') THEN 1 ELSE 0 END) AS write_count,
128
+ SUM(CASE WHEN status = 'slow' THEN 1 ELSE 0 END) AS slow_count,
129
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed_count,
130
+ AVG(duration) AS average_duration
131
+ FROM query_logs ${filter.all.sql}`,
132
+ filter.all.values,
133
+ ).execute(),
134
+ db.unsafe(`SELECT DISTINCT connection FROM query_logs ${filter.range.sql} ORDER BY connection`, filter.range.values).execute() as Promise<Array<{ connection: unknown }>>,
135
+ ])
136
+
137
+ const counts: QueryCounts = {
138
+ total: countOf(counted?.total_count),
139
+ reads: countOf(counted?.read_count),
140
+ writes: countOf(counted?.write_count),
141
+ slow: countOf(counted?.slow_count),
142
+ failed: countOf(counted?.failed_count),
143
+ averageDuration: countOf(counted?.average_duration),
144
+ }
145
+
146
+ const page = pageWithin(query.page, counts.total, query.perPage)
147
+ const rows = counts.total === 0
148
+ ? []
149
+ : await db.unsafe(
150
+ `SELECT ${dashboardQueryColumns.join(', ')} FROM query_logs ${filter.all.sql}
151
+ ORDER BY executed_at DESC, id DESC LIMIT ${Math.trunc(query.perPage)} OFFSET ${Math.trunc((page - 1) * query.perPage)}`,
152
+ filter.all.values,
153
+ ).execute()
154
+
155
+ return {
156
+ rows: rows.map(row => mapDashboardQueryLog(row)),
157
+ page,
158
+ counts,
159
+ connections: connectionRows.map(row => String(row.connection ?? 'unknown')),
160
+ }
161
+ }
@@ -1,15 +1,8 @@
1
1
  import { Action } from '@stacksjs/actions/runtime'
2
2
  import { FailedJob, Job } from '@stacksjs/orm'
3
- import { getGlobalMetrics, queuedJobState } from '@stacksjs/queue'
3
+ import { getGlobalMetrics } from '@stacksjs/queue'
4
4
  import { dashboardOperationalError } from '../dashboard-response'
5
-
6
- interface QueueBucket {
7
- pending: number
8
- active: number
9
- completed: number
10
- failed: number
11
- total: number
12
- }
5
+ import { readQueueCounts } from './queue-stats'
13
6
 
14
7
  // A queued job has no `status` column - `queuedJobState` in @stacksjs/queue
15
8
  // holds the rule (reserved_at means a worker has it; available_at in the
@@ -23,54 +16,23 @@ export default new Action({
23
16
  method: 'GET',
24
17
  async handle() {
25
18
  try {
26
- const [totalJobs, failedJobCount, allJobs, allFailedJobs] = await Promise.all([
19
+ const [totalJobs, failedJobCount, counts] = await Promise.all([
27
20
  Job.count(),
28
21
  FailedJob.count(),
29
- Job.all(),
30
- FailedJob.all(),
22
+ readQueueCounts(),
31
23
  ])
32
24
 
33
- const queueMap: Record<string, QueueBucket> = {}
34
-
35
- // Per-queue failure counts come from failed_jobs, which is where a
36
- // failure actually lands. This column read `status === 'failed'` off the
37
- // jobs table before, and so was always zero.
38
- const failedByQueue = new Map<string, number>()
39
- for (const f of allFailedJobs) {
40
- const queueName = String(f.get('queue') || 'default')
41
- failedByQueue.set(queueName, (failedByQueue.get(queueName) ?? 0) + 1)
42
- }
43
- let active = 0
44
- const completed = 0
45
-
46
- for (const j of allJobs) {
47
- const queueName = String(j.get('queue') || 'default')
48
- const status = queuedJobState({ reserved_at: j.get('reserved_at'), available_at: j.get('available_at') })
49
-
50
- if (!queueMap[queueName]) {
51
- queueMap[queueName] = { pending: 0, active: 0, completed: 0, failed: 0, total: 0 }
52
- }
53
- const bucket = queueMap[queueName]
54
- bucket.total++
55
-
56
- if (status === 'processing') {
57
- bucket.active++
58
- active++
59
- }
60
- else {
61
- bucket.pending++
62
- }
63
- }
64
-
65
- const queues = Object.entries(queueMap).map(([name, data]) => ({
66
- name,
67
- status: data.active > 0 ? 'active' : 'idle',
68
- pending: data.pending,
69
- active: data.active,
70
- completed: data.completed,
71
- failed: failedByQueue.get(name) ?? data.failed,
72
- total: data.total,
25
+ const queues = counts.map(queue => ({
26
+ name: queue.name,
27
+ status: queue.active > 0 ? 'active' : 'idle',
28
+ pending: queue.pending,
29
+ active: queue.active,
30
+ completed: 0,
31
+ failed: queue.failed,
32
+ total: queue.total,
73
33
  }))
34
+ const active = counts.reduce((sum, queue) => sum + queue.active, 0)
35
+ const completed = 0
74
36
 
75
37
  // The in-memory metrics tracker only sees jobs processed since the
76
38
  // current API process started, so it complements (not replaces) the DB