@stacksjs/defaults 0.70.112 → 0.70.114

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.
@@ -0,0 +1,49 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { emailSDK } from '@stacksjs/email'
3
+ import { config } from '@stacksjs/config'
4
+
5
+ export interface InboxItem {
6
+ messageId: string
7
+ from: string
8
+ fromName?: string
9
+ to: string
10
+ subject: string
11
+ date: string
12
+ read: boolean
13
+ preview?: string
14
+ hasAttachments?: boolean
15
+ path: string
16
+ }
17
+
18
+ function defaultMailbox(): string {
19
+ const domain = (config as any)?.email?.domain || 'stacksjs.com'
20
+ return `chris@${domain}`
21
+ }
22
+
23
+ export default new Action({
24
+ name: 'InboxIndexAction',
25
+ description: 'Returns real inbound emails for a mailbox from S3.',
26
+ method: 'GET',
27
+ apiResponse: true,
28
+
29
+ async handle(request: any) {
30
+ try {
31
+ const mailbox = request?.query?.mailbox || defaultMailbox()
32
+ const emails = await emailSDK.getInbox(mailbox, { limit: 1000 })
33
+
34
+ return {
35
+ mailbox,
36
+ total: emails.length,
37
+ emails,
38
+ }
39
+ }
40
+ catch (err) {
41
+ return {
42
+ mailbox: request?.query?.mailbox || defaultMailbox(),
43
+ total: 0,
44
+ emails: [],
45
+ error: err instanceof Error ? err.message : 'unknown error',
46
+ }
47
+ }
48
+ },
49
+ })
@@ -0,0 +1,36 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { emailSDK } from '@stacksjs/email'
3
+ import { config } from '@stacksjs/config'
4
+
5
+ function defaultMailbox(): string {
6
+ const domain = (config as any)?.email?.domain || 'stacksjs.com'
7
+ return `chris@${domain}`
8
+ }
9
+
10
+ export default new Action({
11
+ name: 'InboxMarkReadAction',
12
+ description: 'Marks a single inbound email as read.',
13
+ method: 'POST',
14
+ apiResponse: true,
15
+
16
+ async handle(request: any) {
17
+ try {
18
+ const body = request?.body || {}
19
+ const mailbox = body.mailbox || defaultMailbox()
20
+ const messageId = body.messageId
21
+
22
+ if (!messageId) {
23
+ return { ok: false, error: 'messageId is required' }
24
+ }
25
+
26
+ const ok = await emailSDK.markAsRead(mailbox, messageId)
27
+ return { ok, mailbox, messageId }
28
+ }
29
+ catch (err) {
30
+ return {
31
+ ok: false,
32
+ error: err instanceof Error ? err.message : 'unknown error',
33
+ }
34
+ }
35
+ },
36
+ })
@@ -0,0 +1,44 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { emailSDK } from '@stacksjs/email'
3
+ import { config } from '@stacksjs/config'
4
+
5
+ function defaultMailbox(): string {
6
+ const domain = (config as any)?.email?.domain || 'stacksjs.com'
7
+ return `chris@${domain}`
8
+ }
9
+
10
+ export default new Action({
11
+ name: 'InboxShowAction',
12
+ description: 'Returns the body and metadata of a single inbound email.',
13
+ method: 'GET',
14
+ apiResponse: true,
15
+
16
+ async handle(request: any) {
17
+ try {
18
+ const mailbox = request?.query?.mailbox || defaultMailbox()
19
+ const messageId = request?.params?.id
20
+
21
+ if (!messageId) {
22
+ return { error: 'messageId is required' }
23
+ }
24
+
25
+ const email = await emailSDK.getEmail(mailbox, messageId)
26
+ if (!email) {
27
+ return { error: 'Email not found' }
28
+ }
29
+
30
+ return {
31
+ mailbox,
32
+ messageId,
33
+ html: email.html ?? '',
34
+ text: email.text ?? '',
35
+ metadata: email.metadata,
36
+ }
37
+ }
38
+ catch (err) {
39
+ return {
40
+ error: err instanceof Error ? err.message : 'unknown error',
41
+ }
42
+ }
43
+ },
44
+ })
@@ -0,0 +1,36 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { emailSDK } from '@stacksjs/email'
3
+ import { config } from '@stacksjs/config'
4
+
5
+ function defaultMailbox(): string {
6
+ const domain = (config as any)?.email?.domain || 'stacksjs.com'
7
+ return `chris@${domain}`
8
+ }
9
+
10
+ export default new Action({
11
+ name: 'InboxStatsAction',
12
+ description: 'Returns aggregate inbox statistics (total/unread/read) for a mailbox.',
13
+ method: 'GET',
14
+ apiResponse: true,
15
+
16
+ async handle(request: any) {
17
+ try {
18
+ const mailbox = request?.query?.mailbox || defaultMailbox()
19
+ const stats = await emailSDK.getInboxStats(mailbox)
20
+
21
+ return {
22
+ mailbox,
23
+ ...stats,
24
+ }
25
+ }
26
+ catch (err) {
27
+ return {
28
+ mailbox: request?.query?.mailbox || defaultMailbox(),
29
+ total: 0,
30
+ unread: 0,
31
+ read: 0,
32
+ error: err instanceof Error ? err.message : 'unknown error',
33
+ }
34
+ }
35
+ },
36
+ })
@@ -49,7 +49,7 @@ export default class QueryController extends Controller {
49
49
  db.fn.count('id').as('count'),
50
50
  ])
51
51
  .where('status', '=', 'slow')
52
- .where('executed_at', '>=', sql`datetime("now", "-1 day")` as any)
52
+ .whereRaw(sql`executed_at >= datetime("now", "-1 day")`)
53
53
  .groupBy(sql`strftime("%Y-%m-%d %H:00:00", executed_at)`)
54
54
  .orderBy('hour')
55
55
  .execute()
@@ -84,8 +84,39 @@ export default class QueryController extends Controller {
84
84
  search = '',
85
85
  }) {
86
86
  try {
87
- let query = db
88
- .selectFrom('query_logs')
87
+ // The wrapped builder is mutable: every chained call rewrites the
88
+ // same statement text, so a count projection would clobber the
89
+ // data projection on a shared instance. Route both queries
90
+ // through one filter helper over independent builders instead.
91
+ const applyFilters = (q: any): any => {
92
+ if (connection !== 'all')
93
+ q = q.where('connection', '=', connection)
94
+
95
+ if (status !== 'all')
96
+ q = q.where('status', '=', status as any)
97
+
98
+ if (type !== 'all')
99
+ q = q.where('tags', 'like', `%"${type}"%`)
100
+
101
+ if (search) {
102
+ // Parenthesized OR-group with bound parameters.
103
+ q = q.whereAny(['query', 'model', 'method', 'affected_tables'], 'like', `%${search}%`)
104
+ }
105
+
106
+ return q
107
+ }
108
+
109
+ // Get total count for pagination
110
+ const totalResult = await applyFilters(db.selectFrom('query_logs'))
111
+ .select(db.fn.count('id').as('count'))
112
+ .executeTakeFirstOrThrow()
113
+ const total = Number(totalResult.count)
114
+
115
+ // Apply pagination
116
+ const offset = (page - 1) * perPage
117
+
118
+ // Execute paginated query
119
+ const results = await applyFilters(db.selectFrom('query_logs'))
89
120
  .select([
90
121
  'id',
91
122
  'query',
@@ -100,37 +131,9 @@ export default class QueryController extends Controller {
100
131
  'tags',
101
132
  ])
102
133
  .orderBy('executed_at', 'desc')
103
-
104
- // Apply filters
105
- if (connection !== 'all')
106
- query = query.where('connection', '=', connection)
107
-
108
- if (status !== 'all')
109
- query = query.where('status', '=', status as any)
110
-
111
- if (type !== 'all')
112
- query = query.where('tags', 'like', `%"${type}"%`)
113
-
114
- if (search) {
115
- query = query.where(eb => eb.or([
116
- eb('query', 'like', `%${search}%`),
117
- eb('model', 'like', `%${search}%`),
118
- eb('method', 'like', `%${search}%`),
119
- eb('affected_tables', 'like', `%${search}%`),
120
- ]))
121
- }
122
-
123
- // Get total count for pagination
124
- const countQuery = query.$call(q => q.select(db.fn.count('id').as('count')))
125
- const totalResult = await countQuery.executeTakeFirstOrThrow()
126
- const total = Number(totalResult.count)
127
-
128
- // Apply pagination
129
- const offset = (page - 1) * perPage
130
- query = query.limit(perPage).offset(offset)
131
-
132
- // Execute paginated query
133
- const results = await query.execute()
134
+ .limit(perPage)
135
+ .offset(offset)
136
+ .execute()
134
137
 
135
138
  return {
136
139
  data: results,
@@ -163,8 +166,29 @@ export default class QueryController extends Controller {
163
166
  if (slowThreshold < 0)
164
167
  slowThreshold = config.database?.queryLogging?.slowThreshold || 100
165
168
 
166
- let query = db
167
- .selectFrom('query_logs')
169
+ // Mutable builder: see getRecentQueries for why count and data
170
+ // queries are independent builders sharing one filter helper.
171
+ const applyFilters = (q: any): any => {
172
+ q = q.where('duration', '>=', slowThreshold)
173
+
174
+ if (connection !== 'all')
175
+ q = q.where('connection', '=', connection)
176
+
177
+ if (search) {
178
+ q = q.whereAny(['query', 'model', 'method', 'affected_tables'], 'like', `%${search}%`)
179
+ }
180
+
181
+ return q
182
+ }
183
+
184
+ const totalResult = await applyFilters(db.selectFrom('query_logs'))
185
+ .select(db.fn.count('id').as('count'))
186
+ .executeTakeFirstOrThrow()
187
+ const total = Number(totalResult.count)
188
+
189
+ const offset = (page - 1) * perPage
190
+
191
+ const results = await applyFilters(db.selectFrom('query_logs'))
168
192
  .select([
169
193
  'id',
170
194
  'query',
@@ -181,29 +205,10 @@ export default class QueryController extends Controller {
181
205
  'indexes_used',
182
206
  'missing_indexes',
183
207
  ])
184
- .where('duration', '>=', slowThreshold)
185
208
  .orderBy('duration', 'desc')
186
-
187
- if (connection !== 'all')
188
- query = query.where('connection', '=', connection)
189
-
190
- if (search) {
191
- query = query.where(eb => eb.or([
192
- eb('query', 'like', `%${search}%`),
193
- eb('model', 'like', `%${search}%`),
194
- eb('method', 'like', `%${search}%`),
195
- eb('affected_tables', 'like', `%${search}%`),
196
- ]))
197
- }
198
-
199
- const countQuery = query.$call(q => q.select(db.fn.count('id').as('count')))
200
- const totalResult = await countQuery.executeTakeFirstOrThrow()
201
- const total = Number(totalResult.count)
202
-
203
- const offset = (page - 1) * perPage
204
- query = query.limit(perPage).offset(offset)
205
-
206
- const results = await query.execute()
209
+ .limit(perPage)
210
+ .offset(offset)
211
+ .execute()
207
212
 
208
213
  return {
209
214
  data: results,
@@ -289,11 +294,11 @@ export default class QueryController extends Controller {
289
294
  let query = db
290
295
  .selectFrom('query_logs')
291
296
  .select([
292
- sql`strftime("${interval}", executed_at)`.as('time_interval'),
297
+ sql`strftime(${sql.literal(interval)}, executed_at)`.as('time_interval'),
293
298
  db.fn.count('id').as('count'),
294
299
  db.fn.avg('duration').as('avg_duration'),
295
300
  ])
296
- .where('executed_at', '>=', sql`datetime("now", "${timeConstraint}")` as any)
301
+ .whereRaw(sql`executed_at >= datetime("now", ${sql.literal(timeConstraint)})`)
297
302
  .groupBy('time_interval')
298
303
  .orderBy('time_interval')
299
304
 
@@ -355,13 +360,16 @@ export default class QueryController extends Controller {
355
360
  try {
356
361
  const retentionDays = config.database?.queryLogging?.retention || 7
357
362
 
358
- const result = await db
359
- .deleteFrom('query_logs')
360
- .where('executed_at', '<', sql`datetime("now", "-${retentionDays} day")` as any)
361
- .executeTakeFirst()
363
+ // Parameterized via db.unsafe: the delete builder can neither
364
+ // bind a datetime() expression nor render raw WHERE fragments.
365
+ const statement = await (db as any).unsafe(
366
+ `DELETE FROM query_logs WHERE executed_at < datetime("now", ?)`,
367
+ [`-${retentionDays} day`],
368
+ )
369
+ const result = typeof statement?.execute === 'function' ? await statement.execute() : statement
362
370
 
363
371
  return {
364
- pruned: result.numDeletedRows,
372
+ pruned: Number(result?.changes ?? result?.numDeletedRows ?? 0),
365
373
  retentionDays,
366
374
  }
367
375
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.112",
5
+ "version": "0.70.114",
6
6
  "description": "Default Stacks scaffold resources (views, layouts, components, preloader) — shipped so apps consuming the framework from node_modules get the same fallback resources a vendored checkout provides. Source of truth: storage/framework/defaults.",
7
7
  "author": "Chris Breuer",
8
8
  "license": "MIT",
@@ -16,6 +16,9 @@
16
16
  "./*": "./*",
17
17
  "./package.json": "./package.json"
18
18
  },
19
+ "dependencies": {
20
+ "@iconify-json/f7": "^1.2.2"
21
+ },
19
22
  "scripts": {
20
23
  "build": "bun build.ts"
21
24
  }