@stacksjs/defaults 0.75.482 → 0.75.484

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,311 @@
1
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
2
+ import type { CommentableRow, CommentStatus, DashboardComment } from './comment-input'
3
+ import { COMMENT_STATUSES, commentableRecord, dashboardStatusForCommentable, isMissingTableError, isSafeTableName, newestFirst, normalizeCommentStatus, parseCommentableRow } from './comment-input'
4
+ import type { ListQuery, QueryReader } from './content-list'
5
+ import { countOf, pageWithin, parseListQuery, queryText } from './content-list'
6
+
7
+ /**
8
+ * One page of the dashboard's comments, read by the database.
9
+ *
10
+ * Comments live in two tables: `comments`, the CMS table, and `commentables`,
11
+ * the polymorphic one the `commentable` trait writes. The list used to read
12
+ * both whole and the page rendered every comment at once
13
+ * (stacksjs/stacks#3193). Now each table is searched, filtered and counted in
14
+ * SQL. A page is newest first across both: for page p of n, each table gives
15
+ * its newest p * n matches, which must hold the page, and the two are merged
16
+ * by date and sliced. That costs more for deep pages, never more than the
17
+ * matching rows.
18
+ */
19
+
20
+ export type CommentListQuery = ListQuery<'created_at'> & { status: CommentStatus | 'all' }
21
+
22
+ export interface CommentCounts {
23
+ all: number
24
+ pending: number
25
+ approved: number
26
+ spam: number
27
+ trash: number
28
+ }
29
+
30
+ export interface CommentPage {
31
+ rows: DashboardComment[]
32
+ /** Comments matching the search and status, which the pager counts. */
33
+ total: number
34
+ /** The page the rows are, moved back to the last one when it ran past it. */
35
+ page: number
36
+ /** Every comment by status, whatever the search, for the summary cards. */
37
+ counts: CommentCounts
38
+ }
39
+
40
+ export function parseCommentListQuery(read: QueryReader): CommentListQuery | { error: string } {
41
+ const list = parseListQuery(read, ['created_at'] as const)
42
+ if ('error' in list)
43
+ return list
44
+ if (list.direction !== 'desc')
45
+ return { error: 'Comments are listed newest first; direction must be desc.' }
46
+
47
+ const status = queryText(read, 'status') || 'all'
48
+ if (status !== 'all' && !(COMMENT_STATUSES as readonly string[]).includes(status))
49
+ return { error: `status must be all or one of ${COMMENT_STATUSES.join(', ')}.` }
50
+
51
+ return { ...list, status: status as CommentListQuery['status'] }
52
+ }
53
+
54
+ /** A timestamp as text that sorts: PostgreSQL hands back a Date, SQLite a string. */
55
+ function timestampText(value: unknown): string | null {
56
+ if (value instanceof Date)
57
+ return Number.isNaN(value.getTime()) ? null : value.toISOString()
58
+ return value === undefined || value === null || value === '' ? null : String(value)
59
+ }
60
+
61
+ /* ------------------------------------------------------------ comments */
62
+
63
+ interface CommentRow {
64
+ id: number
65
+ author_name: string | null
66
+ author_email: string | null
67
+ content: string | null
68
+ body: string | null
69
+ post_title: string | null
70
+ status: string | null
71
+ is_approved: number | boolean | null
72
+ created_at: unknown
73
+ updated_at: unknown
74
+ }
75
+
76
+ function legacyRecord(row: CommentRow): DashboardComment {
77
+ return {
78
+ key: `comments:${row.id}`,
79
+ source: 'comments',
80
+ id: Number(row.id),
81
+ author_name: String(row.author_name || ''),
82
+ author_email: String(row.author_email || ''),
83
+ content: String(row.content || row.body || ''),
84
+ post_title: String(row.post_title || ''),
85
+ status: normalizeCommentStatus(row.status),
86
+ is_approved: Boolean(row.is_approved),
87
+ created_at: timestampText(row.created_at),
88
+ updated_at: timestampText(row.updated_at),
89
+ }
90
+ }
91
+
92
+ /** `comments.status` is an enum or CHECK of exactly these values, so it compares as is. */
93
+ function legacyFiltered(query: CommentListQuery): any {
94
+ let chain: any = db.selectFrom('comments')
95
+ if (query.status !== 'all')
96
+ chain = chain.where('status', '=', query.status)
97
+ if (query.search) {
98
+ const term = `%${query.search}%`
99
+ chain = chain.whereGroup((group: any) => group
100
+ .whereLike('content', term)
101
+ .orWhereLike('body', term)
102
+ .orWhereLike('author_name', term)
103
+ .orWhereLike('author_email', term)
104
+ .orWhereLike('post_title', term))
105
+ }
106
+ return chain
107
+ }
108
+
109
+ async function legacyTotal(query: CommentListQuery): Promise<number> {
110
+ return countOf((await legacyFiltered(query).select(db.fn.count('id').as('count')).executeTakeFirst() as { count?: unknown } | undefined)?.count)
111
+ }
112
+
113
+ async function legacyRows(query: CommentListQuery, limit: number): Promise<DashboardComment[]> {
114
+ const rows = await legacyFiltered(query)
115
+ .select(['id', 'author_name', 'author_email', 'content', 'body', 'post_title', 'status', 'is_approved', 'created_at', 'updated_at'])
116
+ .orderBy('created_at', 'desc')
117
+ .orderBy('id', 'desc')
118
+ .limit(limit)
119
+ .execute() as CommentRow[]
120
+
121
+ return rows.map(legacyRecord)
122
+ }
123
+
124
+ /* -------------------------------------------------------- commentables */
125
+
126
+ /**
127
+ * The column that names each kind of thing a trait comment was left on, by
128
+ * table: its title, else its name. A type that is not a safe identifier, has
129
+ * no such table, or has neither column has none, and its comments show a
130
+ * plain `posts #3` label, as they always did.
131
+ */
132
+ async function ownerLabelColumns(types: string[]): Promise<Map<string, 'title' | 'name'>> {
133
+ const columns = new Map<string, 'title' | 'name'>()
134
+ for (const type of types) {
135
+ if (!isSafeTableName(type))
136
+ continue
137
+ for (const column of ['title', 'name'] as const) {
138
+ try {
139
+ await db.unsafe(`SELECT id, ${column} FROM ${type} WHERE 1 = 0`).execute()
140
+ columns.set(type, column)
141
+ break
142
+ }
143
+ catch {
144
+ // No such table or no such column: try the next column, then none.
145
+ }
146
+ }
147
+ }
148
+ return columns
149
+ }
150
+
151
+ /** What each trait comment on the page was left on, keyed `type:id`. */
152
+ async function ownerLabels(rows: CommentableRow[], columns: Map<string, 'title' | 'name'>): Promise<Map<string, string>> {
153
+ const labels = new Map<string, string>()
154
+ const idsByType = new Map<string, number[]>()
155
+
156
+ for (const row of rows) {
157
+ const type = String(row.commentables_type || '')
158
+ labels.set(`${type}:${row.commentables_id}`, `${type} #${row.commentables_id}`)
159
+ if (columns.has(type))
160
+ idsByType.set(type, [...(idsByType.get(type) ?? []), Number(row.commentables_id)])
161
+ }
162
+
163
+ for (const [type, ids] of idsByType) {
164
+ const column = columns.get(type)!
165
+ const owners = await (db as any).selectFrom(type).select(['id', column]).whereIn('id', [...new Set(ids)]).execute() as Array<Record<string, unknown>>
166
+ for (const owner of owners) {
167
+ const label = String(owner[column] ?? '').trim()
168
+ if (label)
169
+ labels.set(`${type}:${owner.id}`, label)
170
+ }
171
+ }
172
+
173
+ return labels
174
+ }
175
+
176
+ /**
177
+ * The trait's status column is free text, so it is compared lowercased. It has
178
+ * one hidden state, `rejected`, which the dashboard shows as spam, and none
179
+ * that shows as trash. The values are constants, never input.
180
+ */
181
+ const COMMENTABLE_STATUS_SQL: Record<CommentStatus, string> = {
182
+ approved: `LOWER(status) = 'approved'`,
183
+ spam: `LOWER(status) = 'rejected'`,
184
+ trash: '1 = 0',
185
+ pending: `(status IS NULL OR LOWER(status) NOT IN ('approved', 'rejected'))`,
186
+ }
187
+
188
+ /**
189
+ * The WHERE clause for trait comments, and its bound values.
190
+ *
191
+ * Raw rather than the builder because a match on what a comment was left on
192
+ * is a subquery per owner table, and the builder takes no bound values in a
193
+ * raw fragment. Every value here is bound; only table and column names that
194
+ * passed `isSafeTableName` and the probe above are written into the text.
195
+ */
196
+ function commentableWhere(query: CommentListQuery, columns: Map<string, 'title' | 'name'>, guestAuthors: boolean): { sql: string, values: unknown[] } {
197
+ const helpers = sqlHelpers(getDatabaseDialect())
198
+ const clauses: string[] = []
199
+ const values: unknown[] = []
200
+ const bind = (value: unknown): string => {
201
+ values.push(value)
202
+ return helpers.param(values.length)
203
+ }
204
+
205
+ if (query.status !== 'all')
206
+ clauses.push(COMMENTABLE_STATUS_SQL[query.status])
207
+
208
+ if (query.search) {
209
+ const term = `%${query.search}%`
210
+ const searched = guestAuthors ? ['title', 'body', 'author_name', 'author_email'] : ['title', 'body']
211
+ const matches = searched.map(column => `LOWER(${column}) LIKE LOWER(${bind(term)})`)
212
+ for (const [type, column] of columns)
213
+ matches.push(`(commentables_type = '${type}' AND commentables_id IN (SELECT id FROM ${type} WHERE LOWER(${column}) LIKE LOWER(${bind(term)})))`)
214
+ clauses.push(`(${matches.join(' OR ')})`)
215
+ }
216
+
217
+ return { sql: clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '', values }
218
+ }
219
+
220
+ interface CommentableSource {
221
+ columns: Map<string, 'title' | 'name'>
222
+ guestAuthors: boolean
223
+ where: { sql: string, values: unknown[] }
224
+ total: number
225
+ statusRows: Array<{ status: unknown, count: unknown }>
226
+ }
227
+
228
+ /** The trait comments' filter, match count and status counts, or null without the table. */
229
+ async function prepareCommentables(query: CommentListQuery): Promise<CommentableSource | null> {
230
+ let types: string[]
231
+ try {
232
+ const found = await db.unsafe('SELECT DISTINCT commentables_type FROM commentables').execute() as Array<{ commentables_type: string }>
233
+ types = found.map(row => String(row.commentables_type))
234
+ }
235
+ catch (error) {
236
+ // An app that never migrated the trait tables has no trait comments,
237
+ // which is not an error; any other failure is.
238
+ if (isMissingTableError(error))
239
+ return null
240
+ throw error
241
+ }
242
+
243
+ // The guest-author columns came after the table, and `migrate` adds them.
244
+ // Until it has, a trait comment has no author name or email, as before,
245
+ // rather than the list failing.
246
+ let guestAuthors = true
247
+ try {
248
+ await db.unsafe('SELECT author_name, author_email FROM commentables WHERE 1 = 0').execute()
249
+ }
250
+ catch {
251
+ guestAuthors = false
252
+ }
253
+
254
+ const columns = await ownerLabelColumns(types)
255
+ const where = commentableWhere(query, columns, guestAuthors)
256
+ const [[counted], statusRows] = await Promise.all([
257
+ db.unsafe(`SELECT COUNT(id) AS count FROM commentables ${where.sql}`, where.values).execute() as Promise<Array<{ count: unknown }>>,
258
+ db.unsafe('SELECT status, COUNT(id) AS count FROM commentables GROUP BY status').execute() as Promise<Array<{ status: unknown, count: unknown }>>,
259
+ ])
260
+
261
+ return { columns, guestAuthors, where, total: countOf(counted?.count), statusRows }
262
+ }
263
+
264
+ async function commentableRows(source: CommentableSource, limit: number): Promise<DashboardComment[]> {
265
+ const rows = (await db.unsafe(
266
+ `SELECT id, title, body, status, commentables_id, commentables_type, user_id, ${source.guestAuthors ? 'author_name, author_email' : 'NULL AS author_name, NULL AS author_email'}, created_at, updated_at
267
+ FROM commentables ${source.where.sql} ORDER BY created_at DESC, id DESC LIMIT ${Math.trunc(limit)}`,
268
+ source.where.values,
269
+ ).execute()).map(parseCommentableRow)
270
+
271
+ const labels = await ownerLabels(rows, source.columns)
272
+ return rows.map(row => ({
273
+ ...commentableRecord(row, labels.get(`${row.commentables_type}:${row.commentables_id}`) ?? ''),
274
+ created_at: timestampText(row.created_at),
275
+ updated_at: timestampText(row.updated_at),
276
+ }))
277
+ }
278
+
279
+ /* ---------------------------------------------------------------- page */
280
+
281
+ async function legacyCounts(): Promise<Array<{ status: unknown, count: unknown }>> {
282
+ return await db.selectFrom('comments').select(['status', db.fn.count('id').as('count')]).groupBy('status').execute() as Array<{ status: unknown, count: unknown }>
283
+ }
284
+
285
+ export async function readCommentPage(query: CommentListQuery): Promise<CommentPage> {
286
+ // The totals first, so the page can be moved back inside them before any
287
+ // row is read.
288
+ const [commentTotal, trait, legacyStatusRows] = await Promise.all([legacyTotal(query), prepareCommentables(query), legacyCounts()])
289
+ const total = commentTotal + (trait?.total ?? 0)
290
+ const page = pageWithin(query.page, total, query.perPage)
291
+ const limit = page * query.perPage
292
+
293
+ const [legacy, traitRows] = await Promise.all([
294
+ commentTotal > 0 ? legacyRows(query, limit) : Promise.resolve([]),
295
+ trait && trait.total > 0 ? commentableRows(trait, limit) : Promise.resolve([]),
296
+ ])
297
+
298
+ const counts: CommentCounts = { all: 0, pending: 0, approved: 0, spam: 0, trash: 0 }
299
+ for (const row of legacyStatusRows)
300
+ counts[normalizeCommentStatus(row.status)] += countOf(row.count)
301
+ for (const row of trait?.statusRows ?? [])
302
+ counts[dashboardStatusForCommentable(row.status)] += countOf(row.count)
303
+ counts.all = counts.pending + counts.approved + counts.spam + counts.trash
304
+
305
+ return {
306
+ rows: [...legacy, ...traitRows].sort(newestFirst).slice((page - 1) * query.perPage, limit),
307
+ total,
308
+ page,
309
+ counts,
310
+ }
311
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The query a paged dashboard content list takes, read and checked once for
3
+ * every list that pages on the server (stacksjs/stacks#3191, #3192).
4
+ */
5
+
6
+ export const LIST_PAGE_SIZES = [10, 25, 50, 100] as const
7
+
8
+ export interface ListQuery<Sort extends string> {
9
+ page: number
10
+ perPage: number
11
+ search: string
12
+ sort: Sort
13
+ direction: 'asc' | 'desc'
14
+ }
15
+
16
+ /** Reads one query value, in whatever shape the request holds it. */
17
+ export type QueryReader = (key: string) => unknown
18
+
19
+ /** One value as a string: the first of a repeated key, or empty when absent. */
20
+ export function queryText(read: QueryReader, key: string): string {
21
+ const value = read(key)
22
+ if (Array.isArray(value))
23
+ return value.length > 0 ? String(value[0]).trim() : ''
24
+ return value === undefined || value === null ? '' : String(value).trim()
25
+ }
26
+
27
+ /**
28
+ * The page, size, search and sort, or the reason they are invalid.
29
+ *
30
+ * Absent values take the defaults these pages have always shown: the first
31
+ * page of 25, newest first. A value that is present and wrong is an error
32
+ * rather than a silent default, so a broken caller is visible.
33
+ */
34
+ export function parseListQuery<Sort extends string>(read: QueryReader, sorts: readonly Sort[]): ListQuery<Sort> | { error: string } {
35
+ const pageText = queryText(read, 'page')
36
+ const page = pageText === '' ? 1 : Number(pageText)
37
+ if (!Number.isInteger(page) || page < 1)
38
+ return { error: 'page must be a whole number of 1 or more.' }
39
+
40
+ const sizeText = queryText(read, 'per_page')
41
+ const perPage = sizeText === '' ? 25 : Number(sizeText)
42
+ if (!(LIST_PAGE_SIZES as readonly number[]).includes(perPage))
43
+ return { error: `per_page must be one of ${LIST_PAGE_SIZES.join(', ')}.` }
44
+
45
+ const sort = queryText(read, 'sort') || 'created_at'
46
+ if (!(sorts as readonly string[]).includes(sort))
47
+ return { error: `sort must be one of ${sorts.join(', ')}.` }
48
+
49
+ const directionText = queryText(read, 'direction').toLowerCase()
50
+ const direction = directionText === '' ? (sort === 'created_at' ? 'desc' : 'asc') : directionText
51
+ if (direction !== 'asc' && direction !== 'desc')
52
+ return { error: 'direction must be asc or desc.' }
53
+
54
+ const search = queryText(read, 'search')
55
+ if (search.length > 200)
56
+ return { error: 'search must be 200 characters or fewer.' }
57
+
58
+ return { page, perPage, search, sort: sort as Sort, direction }
59
+ }
60
+
61
+ /** The page to show: the one asked for, or the last one when it ran past the end. */
62
+ export function pageWithin(page: number, total: number, perPage: number): number {
63
+ return Math.min(page, Math.max(1, Math.ceil(total / perPage)))
64
+ }
65
+
66
+ /** A COUNT or SUM read back as a number, whichever type the driver returned. */
67
+ export function countOf(value: unknown): number {
68
+ return Number(value ?? 0) || 0
69
+ }
@@ -0,0 +1,108 @@
1
+ import { db, sql } from '@stacksjs/database/runtime'
2
+ import type { ListQuery, QueryReader } from './content-list'
3
+ import { countOf, pageWithin, parseListQuery } from './content-list'
4
+
5
+ /**
6
+ * One page of the dashboard's CMS pages list, read by the database.
7
+ *
8
+ * The list used to `SELECT *` every page, block documents included, and hand
9
+ * the whole table to the browser to search and page (stacksjs/stacks#3192).
10
+ * Now the database searches, sorts, counts and pages, and a row carries only
11
+ * the columns the list shows and the editor edits.
12
+ */
13
+
14
+ export const PAGE_SORTS = ['created_at', 'title', 'published'] as const
15
+
16
+ export type PageSort = typeof PAGE_SORTS[number]
17
+ export type PageListQuery = ListQuery<PageSort>
18
+
19
+ export interface PageListRow {
20
+ id: number
21
+ title: string | null
22
+ template: string | null
23
+ views: number | null
24
+ conversions: number | null
25
+ published_at: string | null
26
+ author_id: number | null
27
+ created_at: string | null
28
+ updated_at: string | null
29
+ }
30
+
31
+ export interface PageListPage {
32
+ rows: PageListRow[]
33
+ /** Pages matching the search, which the pager counts. */
34
+ total: number
35
+ /** The page the rows are, moved back to the last one when it ran past it. */
36
+ page: number
37
+ /** Every page, whatever the search, for the summary cards. */
38
+ allCount: number
39
+ totalViews: number
40
+ totalConversions: number
41
+ /** The most recently created page's title, or empty when there are none. */
42
+ newestTitle: string
43
+ }
44
+
45
+ export function parsePageListQuery(read: QueryReader): PageListQuery | { error: string } {
46
+ return parseListQuery(read, PAGE_SORTS)
47
+ }
48
+
49
+ /** The search that a page and its count share: the title, and the template shown under it. */
50
+ function filtered(query: PageListQuery, database: typeof db): any {
51
+ let chain: any = database.selectFrom('pages')
52
+ if (query.search) {
53
+ const term = `%${query.search}%`
54
+ chain = chain.whereGroup((group: any) => group
55
+ .whereLike('title', term)
56
+ .orWhereLike('template', term))
57
+ }
58
+ return chain
59
+ }
60
+
61
+ /**
62
+ * The same order on every dialect. "Published" is whether a page has a
63
+ * publish date, drafts first ascending as the table always sorted the badge,
64
+ * then by that date; titles compare without case. The id breaks ties so a
65
+ * page boundary is stable between requests.
66
+ */
67
+ function ordered(chain: any, query: PageListQuery): any {
68
+ const direction = query.direction === 'asc' ? 'ASC' : 'DESC'
69
+ if (query.sort === 'published')
70
+ chain = chain.orderByRaw(sql.raw(`CASE WHEN published_at IS NULL THEN 0 ELSE 1 END ${direction}, published_at ${direction}`))
71
+ else if (query.sort === 'title')
72
+ chain = chain.orderByRaw(sql.raw(`LOWER(title) ${direction}`))
73
+ else
74
+ chain = chain.orderBy(query.sort, query.direction)
75
+ return chain.orderBy('id', 'desc')
76
+ }
77
+
78
+ export async function readPageListPage(query: PageListQuery, database: typeof db = db): Promise<PageListPage> {
79
+ const total = countOf((await filtered(query, database).select(database.fn.count('id').as('count')).executeTakeFirst() as { count?: unknown } | undefined)?.count)
80
+ const page = pageWithin(query.page, total, query.perPage)
81
+
82
+ const rows = total === 0
83
+ ? []
84
+ : await ordered(filtered(query, database), query)
85
+ .select(['id', 'title', 'template', 'views', 'conversions', 'published_at', 'author_id', 'created_at', 'updated_at'])
86
+ .limit(query.perPage)
87
+ .offset((page - 1) * query.perPage)
88
+ .execute() as PageListRow[]
89
+
90
+ const [totals, newest] = await Promise.all([
91
+ database.selectFrom('pages').select([
92
+ database.fn.count('id').as('count'),
93
+ database.fn.sum('views').as('views'),
94
+ database.fn.sum('conversions').as('conversions'),
95
+ ]).executeTakeFirst() as Promise<{ count?: unknown, views?: unknown, conversions?: unknown } | undefined>,
96
+ database.selectFrom('pages').select(['title']).orderBy('created_at', 'desc').orderBy('id', 'desc').limit(1).executeTakeFirst() as Promise<{ title?: string | null } | undefined>,
97
+ ])
98
+
99
+ return {
100
+ rows,
101
+ total,
102
+ page,
103
+ allCount: countOf(totals?.count),
104
+ totalViews: countOf(totals?.views),
105
+ totalConversions: countOf(totals?.conversions),
106
+ newestTitle: String(newest?.title || ''),
107
+ }
108
+ }
@@ -0,0 +1,140 @@
1
+ import { db, sql } from '@stacksjs/database/runtime'
2
+ import type { ListQuery, QueryReader } from './content-list'
3
+ import { countOf, LIST_PAGE_SIZES, pageWithin, parseListQuery, queryText } from './content-list'
4
+
5
+ /**
6
+ * One page of the dashboard's posts list, read by the database.
7
+ *
8
+ * The list used to read every post, article bodies included, and hand the
9
+ * whole table to the browser to search and page through 25 at a time
10
+ * (stacksjs/stacks#3191). Now the database filters, counts, sorts and pages,
11
+ * and a row carries a short preview of its body instead of all of it. The
12
+ * editor reads one post's body when it opens (`PostShowAction`).
13
+ */
14
+
15
+ export const POST_PAGE_SIZES = LIST_PAGE_SIZES
16
+ export const POST_STATUSES = ['draft', 'published', 'archived'] as const
17
+ export const POST_SORTS = ['created_at', 'title', 'status', 'views'] as const
18
+
19
+ /** How much of a post's body a list row carries, for posts with no excerpt. */
20
+ export const POST_PREVIEW_LENGTH = 240
21
+
22
+ export type PostStatus = typeof POST_STATUSES[number]
23
+ export type PostSort = typeof POST_SORTS[number]
24
+
25
+ export interface PostListQuery extends ListQuery<PostSort> {
26
+ status: PostStatus | 'all'
27
+ }
28
+
29
+ export interface PostListRow {
30
+ id: number
31
+ title: string
32
+ excerpt: string | null
33
+ preview: string | null
34
+ poster: string | null
35
+ status: string | null
36
+ views: number | null
37
+ published_at: string | null
38
+ created_at: string | null
39
+ updated_at: string | null
40
+ author_id: number | null
41
+ is_featured: number | boolean | null
42
+ }
43
+
44
+ export interface PostPage {
45
+ rows: PostListRow[]
46
+ /** Posts matching the search and status, which the pager counts. */
47
+ total: number
48
+ /** The page the rows are, moved back to the last one when it ran past it. */
49
+ page: number
50
+ /** Every post, whatever the search, for the page's summary cards. */
51
+ allCount: number
52
+ publishedCount: number
53
+ draftCount: number
54
+ archivedCount: number
55
+ totalViews: number
56
+ }
57
+
58
+ /** The list query, with the post status filter on top of the shared one. */
59
+ export function parsePostListQuery(read: QueryReader): PostListQuery | { error: string } {
60
+ const list = parseListQuery(read, POST_SORTS)
61
+ if ('error' in list)
62
+ return list
63
+
64
+ const status = queryText(read, 'status') || 'all'
65
+ if (status !== 'all' && !(POST_STATUSES as readonly string[]).includes(status))
66
+ return { error: `status must be all or one of ${POST_STATUSES.join(', ')}.` }
67
+
68
+ return { ...list, status: status as PostListQuery['status'] }
69
+ }
70
+
71
+ /** The search and status that a page and its count share. */
72
+ function filtered(query: PostListQuery, database: typeof db): any {
73
+ let chain: any = database.selectFrom('posts')
74
+ if (query.status !== 'all')
75
+ chain = chain.where('status', '=', query.status)
76
+ if (query.search) {
77
+ // Bound, and compared lowercased on every dialect. A `%` or `_` the
78
+ // viewer types matches as a wildcard, as it does on the models page. The
79
+ // builder would parenthesise the ORs anyway; the group says so.
80
+ const term = `%${query.search}%`
81
+ chain = chain.whereGroup((group: any) => group
82
+ .whereLike('title', term)
83
+ .orWhereLike('excerpt', term)
84
+ .orWhereLike('content', term))
85
+ }
86
+ return chain
87
+ }
88
+
89
+ /**
90
+ * The order, the same on every dialect. `status` is an enum, which PostgreSQL
91
+ * and MySQL sort by declaration and SQLite sorts as text, so its order is
92
+ * spelled out; titles compare without case, as the table always sorted them.
93
+ * The id last keeps a page boundary stable between requests.
94
+ */
95
+ function ordered(chain: any, query: PostListQuery): any {
96
+ const direction = query.direction === 'asc' ? 'ASC' : 'DESC'
97
+ if (query.sort === 'status')
98
+ chain = chain.orderByRaw(sql.raw(`CASE status WHEN 'published' THEN 0 WHEN 'draft' THEN 1 ELSE 2 END ${direction}`))
99
+ else if (query.sort === 'title')
100
+ chain = chain.orderByRaw(sql.raw(`LOWER(title) ${direction}`))
101
+ else
102
+ chain = chain.orderBy(query.sort, query.direction)
103
+ return chain.orderBy('id', 'desc')
104
+ }
105
+
106
+ export async function readPostPage(query: PostListQuery, database: typeof db = db): Promise<PostPage> {
107
+ const total = countOf((await filtered(query, database).select(database.fn.count('id').as('count')).executeTakeFirst() as { count?: unknown } | undefined)?.count)
108
+ const page = pageWithin(query.page, total, query.perPage)
109
+
110
+ const rows = total === 0
111
+ ? []
112
+ : await ordered(filtered(query, database), query)
113
+ .select([
114
+ 'id', 'title', 'excerpt', 'poster', 'status', 'views', 'published_at', 'created_at', 'updated_at', 'author_id', 'is_featured',
115
+ sql.raw(`SUBSTR(content, 1, ${POST_PREVIEW_LENGTH}) AS preview`),
116
+ ])
117
+ .limit(query.perPage)
118
+ .offset((page - 1) * query.perPage)
119
+ .execute() as PostListRow[]
120
+
121
+ const [byStatus, views] = await Promise.all([
122
+ database.selectFrom('posts').select(['status', database.fn.count('id').as('count')]).groupBy('status').execute() as Promise<Array<{ status: string, count: unknown }>>,
123
+ database.selectFrom('posts').select(database.fn.sum('views').as('views')).executeTakeFirst() as Promise<{ views?: unknown } | undefined>,
124
+ ])
125
+
126
+ const statusCount = (status: PostStatus) => byStatus
127
+ .filter(row => String(row.status).toLowerCase() === status)
128
+ .reduce((sum, row) => sum + countOf(row.count), 0)
129
+
130
+ return {
131
+ rows,
132
+ total,
133
+ page,
134
+ allCount: byStatus.reduce((sum, row) => sum + countOf(row.count), 0),
135
+ publishedCount: statusCount('published'),
136
+ draftCount: statusCount('draft'),
137
+ archivedCount: statusCount('archived'),
138
+ totalViews: countOf(views?.views),
139
+ }
140
+ }
@@ -1,8 +1,7 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions/runtime'
3
- import { FailedJob, Job } from '@stacksjs/orm'
4
3
  import { dashboardOperationalError } from '../dashboard-response'
5
- import { matchesJobSearch, normalizeActiveJob, normalizeFailedJob } from './job-records'
4
+ import { readJobPage } from './job-list'
6
5
 
7
6
  export default new Action({
8
7
  name: 'JobIndexAction',
@@ -16,31 +15,20 @@ export default new Action({
16
15
  const search = String(request.get('search', '')).trim().toLowerCase()
17
16
 
18
17
  try {
19
- const [activeJobs, failedJobs] = await Promise.all([
20
- Job.orderByDesc('id').get(),
21
- FailedJob.orderByDesc('id').get(),
22
- ])
18
+ // An unknown status matched no job before the database did the
19
+ // filtering, and still matches none.
20
+ if (statusFilter && statusFilter !== 'all' && !['queued', 'processing', 'failed'].includes(statusFilter))
21
+ return { data: [], total: 0, page, perPage, queues: [], queueConnected: true }
23
22
 
24
- const activeNormalized = activeJobs.map(normalizeActiveJob)
25
- const failedNormalized = failedJobs.map(normalizeFailedJob)
26
-
27
- const merged = [...activeNormalized, ...failedNormalized].sort((a, b) =>
28
- new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
29
- )
30
-
31
- const filtered = merged.filter((job) => {
32
- if (queueFilter && queueFilter !== 'all' && job.queue !== queueFilter) return false
33
- if (statusFilter && statusFilter !== 'all' && job.status !== statusFilter) return false
34
- if (!matchesJobSearch(job, search)) return false
35
- return true
23
+ const result = await readJobPage({
24
+ page,
25
+ perPage,
26
+ queue: queueFilter === 'all' ? '' : queueFilter,
27
+ status: statusFilter === 'all' ? '' : statusFilter as 'queued' | 'processing' | 'failed' | '',
28
+ search,
36
29
  })
37
30
 
38
- const total = filtered.length
39
- const start = (page - 1) * perPage
40
- const data = filtered.slice(start, start + perPage)
41
- const queues = Array.from(new Set(merged.map(j => j.queue))).sort()
42
-
43
- return { data, total, page, perPage, queues, queueConnected: true }
31
+ return { data: result.data, total: result.total, page: result.page, perPage, queues: result.queues, queueConnected: true }
44
32
  }
45
33
  catch (error) {
46
34
  return dashboardOperationalError(error, 'Job history could not be loaded.', 'JobIndexAction')