@stacksjs/defaults 0.72.6 → 0.72.7

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 (98) hide show
  1. package/app/Actions/Auth/GenerateAuthenticationAction.ts +6 -6
  2. package/app/Actions/Auth/GenerateRegistrationAction.ts +10 -5
  3. package/app/Actions/Auth/RegisterAction.ts +5 -2
  4. package/app/Actions/Auth/VerifyAuthenticationAction.ts +5 -3
  5. package/app/Actions/Auth/VerifyRegistrationAction.ts +7 -3
  6. package/app/Actions/Billable/CheckoutAction.ts +14 -1
  7. package/app/Actions/Cms/AuthorDestroyAction.ts +1 -1
  8. package/app/Actions/Cms/AuthorShowAction.ts +1 -1
  9. package/app/Actions/Cms/AuthorUpdateAction.ts +1 -1
  10. package/app/Actions/Cms/CategorizableDestroyAction.ts +1 -1
  11. package/app/Actions/Cms/CategorizableShowAction.ts +1 -1
  12. package/app/Actions/Cms/CategorizableUpdateAction.ts +2 -3
  13. package/app/Actions/Cms/CommentDestroyAction.ts +1 -1
  14. package/app/Actions/Cms/CommentShowAction.ts +1 -1
  15. package/app/Actions/Cms/CommentUpdateAction.ts +1 -1
  16. package/app/Actions/Cms/PageDestroyAction.ts +1 -1
  17. package/app/Actions/Cms/PageStoreAction.ts +13 -13
  18. package/app/Actions/Cms/PageUpdateAction.ts +11 -8
  19. package/app/Actions/Cms/PostDestroyAction.ts +1 -1
  20. package/app/Actions/Cms/PostShowAction.ts +1 -1
  21. package/app/Actions/Cms/PostUpdateAction.ts +1 -1
  22. package/app/Actions/Cms/TaggableDestroyAction.ts +1 -1
  23. package/app/Actions/Cms/TaggableShowAction.ts +1 -1
  24. package/app/Actions/Cms/TaggableUpdateAction.ts +2 -2
  25. package/app/Actions/Commerce/GiftCardUpdateBalanceAction.ts +1 -1
  26. package/app/Actions/Commerce/WaitlistProductAnalyticsAction.ts +6 -2
  27. package/app/Actions/Commerce/WaitlistProductQuantityDistributionAction.ts +6 -2
  28. package/app/Actions/Commerce/WaitlistProductStatusAction.ts +8 -2
  29. package/app/Actions/Commerce/WaitlistProductTimeSeriesAction.ts +6 -2
  30. package/app/Actions/Dashboard/Analytics/campaign-analytics.ts +6 -1
  31. package/app/Actions/Dashboard/Analytics/request-analytics.ts +3 -3
  32. package/app/Actions/Dashboard/Buddy/buddy-chat.ts +8 -0
  33. package/app/Actions/Dashboard/Ci/runner-pressure-monitor.ts +2 -1
  34. package/app/Actions/Dashboard/Cloud/cloud-overview.ts +3 -1
  35. package/app/Actions/Dashboard/Commerce/CommerceDashboardAction.ts +12 -4
  36. package/app/Actions/Dashboard/Commerce/CommerceOrdersAction.ts +1 -1
  37. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +2 -2
  38. package/app/Actions/Dashboard/Commerce/CommercePrintLogsAction.ts +1 -1
  39. package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +2 -2
  40. package/app/Actions/Dashboard/Commerce/CommerceProductsAction.ts +5 -3
  41. package/app/Actions/Dashboard/Commerce/DeliveryRouteIndexAction.ts +1 -1
  42. package/app/Actions/Dashboard/Commerce/DriverIndexAction.ts +1 -1
  43. package/app/Actions/Dashboard/Commerce/LicenseKeyIndexAction.ts +3 -3
  44. package/app/Actions/Dashboard/Commerce/PaymentRefundAction.ts +4 -4
  45. package/app/Actions/Dashboard/Commerce/ProductUnitDefaultAction.ts +1 -1
  46. package/app/Actions/Dashboard/Commerce/ShippingMethodIndexAction.ts +1 -1
  47. package/app/Actions/Dashboard/Commerce/ShippingRateIndexAction.ts +2 -2
  48. package/app/Actions/Dashboard/Commerce/ShippingZoneIndexAction.ts +1 -1
  49. package/app/Actions/Dashboard/Commerce/TaxRateDefaultAction.ts +1 -1
  50. package/app/Actions/Dashboard/Commerce/commerce-dashboard.ts +20 -9
  51. package/app/Actions/Dashboard/Content/file-manager.ts +3 -2
  52. package/app/Actions/Dashboard/DashboardHomeAction.ts +1 -1
  53. package/app/Actions/Dashboard/DashboardStatsAction.ts +7 -4
  54. package/app/Actions/Dashboard/Deployments/CreateDeploymentRollback.ts +2 -2
  55. package/app/Actions/Dashboard/Deployments/deployment-input.ts +3 -3
  56. package/app/Actions/Dashboard/Deployments/deployment-rollback.ts +12 -0
  57. package/app/Actions/Dashboard/Infrastructure/InsightsAction.ts +3 -3
  58. package/app/Actions/Dashboard/Jobs/job-records.ts +3 -3
  59. package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +5 -3
  60. package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +5 -3
  61. package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +1 -1
  62. package/app/Actions/Dashboard/Kanban/kanban-model.ts +3 -3
  63. package/app/Actions/Dashboard/Kanban/kanban-response.ts +2 -1
  64. package/app/Actions/Dashboard/Library/DependencyIndexAction.ts +1 -0
  65. package/app/Actions/Dashboard/Library/GetReleases.ts +8 -3
  66. package/app/Actions/Dashboard/Marketing/CampaignStoreAction.ts +3 -3
  67. package/app/Actions/Dashboard/Marketing/SocialPostStoreAction.ts +7 -0
  68. package/app/Actions/Dashboard/Marketing/SocialPostUpdateAction.ts +5 -1
  69. package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +12 -9
  70. package/app/Actions/Dashboard/Operations/control-plane.ts +16 -4
  71. package/app/Actions/Dashboard/Queries/QueryDashboardAction.ts +1 -1
  72. package/app/Actions/Dashboard/Queue/QueueStatsAction.ts +23 -14
  73. package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +1 -1
  74. package/app/Actions/Dashboard/Settings/mail-settings.ts +12 -4
  75. package/app/Actions/Dashboard/Source/source-inventory.ts +6 -2
  76. package/app/Actions/Dashboard/dashboard-response.ts +2 -1
  77. package/app/Actions/HealthAction.ts +11 -6
  78. package/app/Actions/Locale/SetLocaleAction.ts +3 -1
  79. package/app/Actions/Monitoring/ErrorShowAction.ts +5 -0
  80. package/app/Actions/Payment/CreatePaymentIntentAction.ts +3 -1
  81. package/app/Actions/SendWelcomeEmail.ts +3 -1
  82. package/app/Actions/Teams/team-response.ts +2 -1
  83. package/app/Actions/TestErrorAction.ts +1 -1
  84. package/app/Jobs/PruneModelsJob.ts +1 -1
  85. package/app/Jobs/PruneQueryLogsJob.ts +3 -1
  86. package/app/Mail/OrderConfirmation.ts +18 -2
  87. package/app/Middleware/Abilities.ts +3 -1
  88. package/app/Middleware/Can.ts +3 -3
  89. package/app/Middleware/Compress.ts +3 -1
  90. package/app/Middleware/Csrf.ts +10 -4
  91. package/app/Middleware/EnsureEmailIsVerified.ts +2 -1
  92. package/app/Middleware/Permission.ts +2 -1
  93. package/app/Middleware/Role.ts +2 -1
  94. package/app/Middleware/Team.ts +2 -1
  95. package/app/Middleware/Throttle.ts +1 -1
  96. package/ide/vscode/package.json +1 -1
  97. package/package.json +2 -2
  98. package/resources/functions/dashboard/data.ts +2 -2
@@ -24,12 +24,12 @@ export default new Action({
24
24
  async handle(request: RequestInstance) {
25
25
  const id = Number(request.getParam('id'))
26
26
  if (!Number.isSafeInteger(id) || id <= 0)
27
- return response.notFound({ error: 'Product not found' })
27
+ return response.notFound('Product not found')
28
28
 
29
29
  try {
30
30
  const product = await Product.find(id)
31
31
  if (!product)
32
- return response.notFound({ error: 'Product not found' })
32
+ return response.notFound('Product not found')
33
33
 
34
34
  const [categories, manufacturers, variants, units, reviews] = await Promise.all([
35
35
  Category.orderBy('name', 'asc').limit(500).get(),
@@ -22,12 +22,14 @@ export default new Action({
22
22
  try {
23
23
  const products = await Product.orderByDesc('id').limit(500).get()
24
24
  const productIds = products.map(product => commerceRecordIdentifier(product, 'Product'))
25
+ // Identifiers normalize to strings; `product_id` is numeric.
26
+ const numericProductIds = productIds.map(Number).filter(Number.isSafeInteger)
25
27
  const [categories, manufacturers, variants, units, reviews] = await Promise.all([
26
28
  Category.orderBy('name', 'asc').limit(500).get(),
27
29
  Manufacturer.orderBy('manufacturer', 'asc').limit(500).get(),
28
- productIds.length > 0 ? ProductVariant.where('product_id', 'in', productIds).get() : [],
29
- productIds.length > 0 ? ProductUnit.where('product_id', 'in', productIds).get() : [],
30
- productIds.length > 0 ? Review.where('product_id', 'in', productIds).get() : [],
30
+ numericProductIds.length > 0 ? ProductVariant.whereIn('product_id', numericProductIds).get() : [],
31
+ numericProductIds.length > 0 ? ProductUnit.whereIn('product_id', numericProductIds).get() : [],
32
+ numericProductIds.length > 0 ? Review.whereIn('product_id', numericProductIds).get() : [],
31
33
  ])
32
34
  const categoryOptions = categories.map(normalizeProductOption)
33
35
  const manufacturerOptions = manufacturers.map(normalizeManufacturerOption)
@@ -16,7 +16,7 @@ export default new Action({
16
16
  try {
17
17
  const routes = await DeliveryRoute.orderByDesc('last_active').limit(500).get()
18
18
  const driverIds = [...new Set(routes.map(route => route.get('driver_id')).filter(Boolean))]
19
- const drivers = driverIds.length ? await Driver.where('id', 'in', driverIds).get() : []
19
+ const drivers = driverIds.length ? await Driver.whereIn('id', driverIds).get() : []
20
20
  const driversById = indexDeliveryRouteDrivers(drivers)
21
21
  return routes.map(route => normalizeDeliveryRouteRecord(route, driversById))
22
22
  }
@@ -13,7 +13,7 @@ export default new Action({
13
13
  try {
14
14
  const drivers = await Driver.orderBy('name', 'asc').limit(500).get()
15
15
  const userIds = [...new Set(drivers.map(driver => driver.get('user_id')).filter(Boolean))]
16
- const users = userIds.length ? await User.where('id', 'in', userIds).get() : []
16
+ const users = userIds.length ? await User.whereIn('id', userIds).get() : []
17
17
  const usersById = indexDriverUsers(users)
18
18
  return drivers.map(driver => normalizeDriverRecord(driver, usersById))
19
19
  }
@@ -21,9 +21,9 @@ export default new Action({
21
21
  const productIds = [...new Set(keys.map(key => key.get('product_id')).filter(Boolean))]
22
22
  const orderIds = [...new Set(keys.map(key => key.get('order_id')).filter(Boolean))]
23
23
  const [customers, products, orders] = await Promise.all([
24
- customerIds.length ? Customer.where('id', 'in', customerIds).get() : [],
25
- productIds.length ? Product.where('id', 'in', productIds).get() : [],
26
- orderIds.length ? Order.where('id', 'in', orderIds).get() : [],
24
+ customerIds.length ? Customer.whereIn('id', customerIds).get() : [],
25
+ productIds.length ? Product.whereIn('id', productIds).get() : [],
26
+ orderIds.length ? Order.whereIn('id', orderIds).get() : [],
27
27
  ])
28
28
  const customerMap = indexLicenseKeyOptions(customers.map(normalizeLicenseKeyCustomer))
29
29
  const productMap = indexLicenseKeyOptions(products.map(normalizeLicenseKeyProduct))
@@ -15,9 +15,9 @@ export default new Action({
15
15
  const id = Number(request.getParam('id'))
16
16
  const amount = request.integer('amount')
17
17
  if (!Number.isSafeInteger(id) || id <= 0)
18
- return response.notFound({ error: 'Payment not found' })
18
+ return response.notFound('Payment not found')
19
19
  if (!Number.isSafeInteger(amount) || amount <= 0)
20
- return response.badRequest({ error: 'Refund amount must be a positive integer in minor units' })
20
+ return response.badRequest('Refund amount must be a positive integer in minor units')
21
21
 
22
22
  try {
23
23
  const payment = await payments.recordRefund(id, amount)
@@ -26,9 +26,9 @@ export default new Action({
26
26
  catch (error) {
27
27
  const message = error instanceof Error ? error.message : String(error)
28
28
  if (message.includes('not found'))
29
- return response.notFound({ error: message })
29
+ return response.notFound(message)
30
30
  if (message.includes('cannot be refunded') || message.includes('exceeds the remaining'))
31
- return response.badRequest({ error: message })
31
+ return response.badRequest(message)
32
32
 
33
33
  log.error('[dashboard:payments] Failed to record refund', { id, error })
34
34
  return response.error('Unable to record the refund.', 500)
@@ -26,7 +26,7 @@ export default new Action({
26
26
  return dashboardOperationalError(error, 'Product unit default could not be updated.', 'ProductUnitDefaultAction', 500)
27
27
  }
28
28
  if (!updated)
29
- return response.notFound({ error: 'Product unit not found' })
29
+ return response.notFound('Product unit not found')
30
30
 
31
31
  return { success: true, id: String(id), isDefault }
32
32
  },
@@ -18,7 +18,7 @@ export default new Action({
18
18
  const methods = await ShippingMethod.orderBy('name', 'asc').limit(500).get()
19
19
  const methodIds = shippingMethodIds(methods)
20
20
  const zones = methodIds.size
21
- ? await ShippingZone.where('shipping_method_id', 'in', [...methodIds]).orderBy('name', 'asc').get()
21
+ ? await ShippingZone.whereIn('shipping_method_id', [...methodIds]).orderBy('name', 'asc').get()
22
22
  : []
23
23
  const zonesByMethodId = groupShippingMethodZones(zones, methodIds)
24
24
  return methods.map(method => normalizeShippingMethodRecord(method, zonesByMethodId))
@@ -19,8 +19,8 @@ export default new Action({
19
19
  const methodIds = [...new Set(rates.map(rate => rate.get('shipping_method_id')).filter(Boolean))]
20
20
  const zoneIds = [...new Set(rates.map(rate => rate.get('shipping_zone_id')).filter(Boolean))]
21
21
  const [methods, zones] = await Promise.all([
22
- methodIds.length ? ShippingMethod.where('id', 'in', methodIds).get() : [],
23
- zoneIds.length ? ShippingZone.where('id', 'in', zoneIds).get() : [],
22
+ methodIds.length ? ShippingMethod.whereIn('id', methodIds).get() : [],
23
+ zoneIds.length ? ShippingZone.whereIn('id', zoneIds).get() : [],
24
24
  ])
25
25
  const methodsById = indexShippingRateMethods(methods)
26
26
  const zonesById = indexShippingRateZones(zones)
@@ -17,7 +17,7 @@ export default new Action({
17
17
  const zones = await ShippingZone.orderBy('name', 'asc').limit(500).get()
18
18
  const methodIds = [...new Set(zones.map(zone => zone.get('shipping_method_id')).filter(Boolean))]
19
19
  const methods = methodIds.length
20
- ? await ShippingMethod.where('id', 'in', methodIds).get()
20
+ ? await ShippingMethod.whereIn('id', methodIds).get()
21
21
  : []
22
22
  const methodsById = indexShippingZoneMethods(methods)
23
23
  return zones.map(zone => normalizeShippingZoneRecord(zone, methodsById))
@@ -26,7 +26,7 @@ export default new Action({
26
26
  return dashboardOperationalError(error, 'Tax rate default could not be updated.', 'TaxRateDefaultAction', 500)
27
27
  }
28
28
  if (!updated)
29
- return response.notFound({ error: 'Tax rate not found' })
29
+ return response.notFound('Tax rate not found')
30
30
 
31
31
  return { success: true, id: String(id), isDefault }
32
32
  },
@@ -198,7 +198,9 @@ function rangeWindow(range: CommerceDashboardRange, now: Date, rows: CommerceDas
198
198
  .map(row => timestamp(row.createdAt))
199
199
  .filter(Number.isFinite)
200
200
  .sort((left, right) => left - right)[0]
201
- const start = Number.isFinite(earliest) ? new Date(earliest) : startOfUtcMonth(now)
201
+ // `earliest` is `number | undefined` (the array may be empty), and
202
+ // `Number.isFinite` does not narrow it for `new Date(...)`.
203
+ const start = earliest !== undefined && Number.isFinite(earliest) ? new Date(earliest) : startOfUtcMonth(now)
202
204
  const elapsedDays = Math.max(0, (now.getTime() - start.getTime()) / DAY_MS)
203
205
  return {
204
206
  start,
@@ -255,8 +257,11 @@ function formatCurrencyTotals(totals: Map<string, number>): { value: string, det
255
257
  const entries = [...totals.entries()].sort((left, right) => right[1] - left[1])
256
258
  if (entries.length === 0)
257
259
  return { value: formatMoney(0, 'USD'), detail: 'No recorded revenue' }
258
- if (entries.length === 1)
259
- return { value: formatMoney(entries[0][1], entries[0][0]), detail: entries[0][0] }
260
+ // Destructured rather than indexed: a `length === 1` check does not narrow
261
+ // `entries[0]` for the compiler, and every index below would need a guard.
262
+ const [only] = entries
263
+ if (entries.length === 1 && only)
264
+ return { value: formatMoney(only[1], only[0]), detail: only[0] }
260
265
  return {
261
266
  value: `${entries.length} currencies`,
262
267
  detail: entries.map(([code, amount]) => formatMoney(amount, code)).join(' | '),
@@ -269,10 +274,11 @@ function formatCurrencyAverages(totals: Map<string, { amount: number, orders: nu
269
274
  .sort((left, right) => right[1].amount - left[1].amount)
270
275
  if (entries.length === 0)
271
276
  return { value: formatMoney(0, 'USD'), detail: 'No recorded orders' }
272
- if (entries.length === 1)
277
+ const [only] = entries
278
+ if (entries.length === 1 && only)
273
279
  return {
274
- value: formatMoney(entries[0][1].amount / entries[0][1].orders, entries[0][0]),
275
- detail: entries[0][0],
280
+ value: formatMoney(only[1].amount / only[1].orders, only[0]),
281
+ detail: only[0],
276
282
  }
277
283
  return {
278
284
  value: 'Mixed currencies',
@@ -329,7 +335,10 @@ function buildBuckets(start: Date, end: Date, bucket: RangeWindow['bucket']): Bu
329
335
  function singleCurrencyChange(current: Map<string, number>, previous: Map<string, number>): string {
330
336
  if (current.size !== 1)
331
337
  return ''
332
- const [[code, amount]] = [...current.entries()]
338
+ const [entry] = [...current.entries()]
339
+ if (!entry)
340
+ return ''
341
+ const [code, amount] = entry
333
342
  return percentChange(amount, previous.get(code) || 0)
334
343
  }
335
344
 
@@ -409,12 +418,14 @@ export function buildCommerceDashboard(
409
418
  const index = bucketIndexes.get(bucketKey(createdAt, window.bucket))
410
419
  if (index === undefined)
411
420
  continue
412
- orderSeries[index]++
421
+ // `index` came out of a Map lookup, so the compiler treats these slots as
422
+ // possibly missing even though the arrays are pre-filled to bucket length.
423
+ orderSeries[index] = (orderSeries[index] ?? 0) + 1
413
424
  if (isCancelled(order.status))
414
425
  continue
415
426
  const code = currency(order.currency)
416
427
  const values = revenueSeries.get(code) || Array.from({ length: buckets.length }, () => 0)
417
- values[index] += order.totalAmount
428
+ values[index] = (values[index] ?? 0) + order.totalAmount
418
429
  revenueSeries.set(code, values)
419
430
  }
420
431
 
@@ -1,3 +1,4 @@
1
+ import type { ResponseStatus } from '@stacksjs/bun-router'
1
2
  import { statfs } from 'node:fs/promises'
2
3
  import { posix } from 'node:path'
3
4
  import type { StorageAdapter, StorageManager, UploadedFileLike } from '@stacksjs/storage'
@@ -52,10 +53,10 @@ export interface DashboardFileSnapshot {
52
53
  }
53
54
 
54
55
  export class DashboardFileError extends Error {
55
- readonly status: number
56
+ readonly status: ResponseStatus
56
57
  readonly fields?: Record<string, string>
57
58
 
58
- constructor(message: string, status = 422, fields?: Record<string, string>) {
59
+ constructor(message: string, status: ResponseStatus = 422, fields?: Record<string, string>) {
59
60
  super(message)
60
61
  this.name = 'DashboardFileError'
61
62
  this.status = status
@@ -142,7 +142,7 @@ export default new Action({
142
142
 
143
143
  const sources = ['Users', 'Products', 'Orders', 'Posts', 'Revenue', 'Recent orders', 'Recent users', 'Request count', 'Recent requests']
144
144
  const issues = modelResults
145
- .map((result, index) => issue(sources[index], result))
145
+ .map((result, index) => issue(sources[index] ?? 'Dashboard', result))
146
146
  .filter((entry): entry is { source: string, message: string } => entry !== null)
147
147
  if (health.status === 'rejected') {
148
148
  issues.push({
@@ -57,15 +57,18 @@ export default new Action({
57
57
  const results = await Promise.allSettled(definitions.map(definition => definition.count()))
58
58
  const stats = definitions.map((definition, index) => {
59
59
  const { count: _count, ...metadata } = definition
60
- return formatDashboardStat(metadata, results[index])
60
+ // allSettled returns one result per input, but indexed access is typed
61
+ // as possibly missing; a rejected placeholder keeps the shape honest.
62
+ const result = results[index] ?? { status: 'rejected' as const, reason: new Error('missing result') }
63
+ return formatDashboardStat(metadata, result)
61
64
  })
62
65
  const issues = results.flatMap((result, index) => result.status === 'rejected'
63
66
  ? [{
64
- source: definitions[index].title,
67
+ source: definitions[index]?.title ?? 'Unknown',
65
68
  message: dashboardOperationalIssue(
66
69
  result.reason,
67
- `${definitions[index].title} data could not be loaded.`,
68
- `DashboardStatsAction.${definitions[index].title.toLowerCase().replaceAll(' ', '-')}`,
70
+ `${definitions[index]?.title ?? 'Dashboard'} data could not be loaded.`,
71
+ `DashboardStatsAction.${(definitions[index]?.title ?? 'unknown').toLowerCase().replaceAll(' ', '-')}`,
69
72
  ),
70
73
  }]
71
74
  : [])
@@ -3,7 +3,7 @@ import { Action } from '@stacksjs/actions'
3
3
  import { response } from '@stacksjs/router'
4
4
  import { trackOperatorOperation } from '../Operations/control-plane'
5
5
  import { stringValue } from '../Operations/recovery-input'
6
- import { deploymentRollbackInput, DeploymentRollbackError, executeDeploymentRollback } from './deployment-rollback'
6
+ import { DeploymentRollbackError, deploymentRollbackInput, executeDeploymentRollback, rollbackAuditPayload } from './deployment-rollback'
7
7
 
8
8
  export default new Action({
9
9
  name: 'CreateDeploymentRollback',
@@ -14,7 +14,7 @@ export default new Action({
14
14
  const body = request.all() as Record<string, unknown>
15
15
  try {
16
16
  const input = deploymentRollbackInput(body)
17
- const tracked = await trackOperatorOperation(request, 'dashboard.deployments.rollback', input, () => executeDeploymentRollback(input, stringValue(body.revision), stringValue(body.confirmation)))
17
+ const tracked = await trackOperatorOperation(request, 'dashboard.deployments.rollback', rollbackAuditPayload(input), () => executeDeploymentRollback(input, stringValue(body.revision), stringValue(body.confirmation)))
18
18
  return { success: true, ...tracked.result, operation: tracked.operation }
19
19
  }
20
20
  catch (error) {
@@ -1,11 +1,11 @@
1
+ import type { ReadableRecord } from '@stacksjs/orm'
1
2
  export interface DeploymentCommandInput {
2
3
  environment?: unknown
3
4
  domain?: unknown
4
5
  }
5
6
 
6
- export interface DeploymentRecordLike {
7
- get: (key: string) => unknown
8
- }
7
+ /** The shared shape, kept under this name for the helpers below. */
8
+ export type DeploymentRecordLike = ReadableRecord
9
9
 
10
10
  export function booleanValue(value: unknown): boolean {
11
11
  return value === true || value === 1 || value === '1' || value === 'true' || value === 'on'
@@ -6,6 +6,18 @@ export interface DeploymentRollbackInput {
6
6
  release?: string
7
7
  }
8
8
 
9
+ /**
10
+ * The same input as a value the operations log can store.
11
+ *
12
+ * `JsonValue` has no room for `undefined`, so the optional fields are dropped
13
+ * rather than recorded as absent-but-present keys.
14
+ */
15
+ export function rollbackAuditPayload(input: DeploymentRollbackInput): Record<string, string> {
16
+ return Object.fromEntries(
17
+ Object.entries(input).filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
18
+ )
19
+ }
20
+
9
21
  export class DeploymentRollbackError extends Error {
10
22
  constructor(message: string) {
11
23
  super(message)
@@ -181,7 +181,7 @@ export default new Action({
181
181
  averageDurationMs: finiteNumber(requestSummary.value?.average),
182
182
  maximumDurationMs: finiteNumber(requestSummary.value?.maximum),
183
183
  latestAt: requestSummary.value?.latest || '',
184
- slowest: slowRequests.value.map(row => ({
184
+ slowest: slowRequests.value.map((row: Record<string, unknown>) => ({
185
185
  id: countValue(row.id),
186
186
  method: String(row.method || 'GET'),
187
187
  path: safeRequestPath(row.path),
@@ -200,7 +200,7 @@ export default new Action({
200
200
  averageDurationMs: finiteNumber(querySummary.value?.average),
201
201
  maximumDurationMs: finiteNumber(querySummary.value?.maximum),
202
202
  latestAt: querySummary.value?.latest || '',
203
- slowest: slowQueries.value.map(row => ({
203
+ slowest: slowQueries.value.map((row: Record<string, unknown>) => ({
204
204
  id: countValue(row.id),
205
205
  query: compactSql(row.query),
206
206
  durationMs: finiteNumber(row.duration),
@@ -240,7 +240,7 @@ export default new Action({
240
240
  errors: {
241
241
  total: countValue(errorSummary.value?.total),
242
242
  latestAt: errorSummary.value?.latest || '',
243
- recent: recentErrors.value.map(row => ({
243
+ recent: recentErrors.value.map((row: Record<string, unknown>) => ({
244
244
  id: countValue(row.id),
245
245
  type: String(row.type || 'Error'),
246
246
  message: String(row.message || ''),
@@ -1,9 +1,9 @@
1
+ import type { ReadableRecord } from '@stacksjs/orm'
1
2
  export type JobRecordSource = 'job' | 'failed'
2
3
  export type DashboardJobStatus = 'queued' | 'processing' | 'failed'
3
4
 
4
- export interface ModelRecord {
5
- get: (key: string) => unknown
6
- }
5
+ /** The shared shape, kept under this name for the helpers below. */
6
+ export type ModelRecord = ReadableRecord
7
7
 
8
8
  export interface NormalizedJob {
9
9
  id: string
@@ -36,7 +36,9 @@ export default new Action({
36
36
  if (!name || name.length > 120) {
37
37
  return kanbanError('Name is required and must be 1-120 characters.', 400)
38
38
  }
39
- const description = typeof body.description === 'string' ? body.description.trim() : null
39
+ // `undefined` rather than `null`: the model treats the column as optional,
40
+ // and "no description was sent" is not the same as "set it to null".
41
+ const description = typeof body.description === 'string' ? body.description.trim() : undefined
40
42
  const icon = typeof body.icon === 'string' && body.icon ? body.icon : 'rectangle.stack.fill'
41
43
  const color = typeof body.color === 'string' && body.color ? body.color : 'violet'
42
44
 
@@ -71,8 +73,8 @@ export default new Action({
71
73
  position: nextPosition,
72
74
  archived: false,
73
75
  cardCount: 0,
74
- createdAt: board.get('createdAt') ?? board.get('created_at') ?? null,
75
- updatedAt: board.get('updatedAt') ?? board.get('updated_at') ?? null,
76
+ createdAt: board.get('created_at') ?? null,
77
+ updatedAt: board.get('updated_at') ?? null,
76
78
  },
77
79
  }
78
80
  }
@@ -40,7 +40,9 @@ export default new Action({
40
40
  if (!title || title.length > 300) {
41
41
  return kanbanError('`title` is required and must be 1-300 characters.', 400)
42
42
  }
43
- const description = typeof body.description === 'string' ? body.description.trim() : null
43
+ // `undefined` rather than `null`: the model treats the column as optional,
44
+ // and "no description was sent" is not the same as "set it to null".
45
+ const description = typeof body.description === 'string' ? body.description.trim() : undefined
44
46
  const dueDate = typeof body.dueDate === 'string' && body.dueDate ? body.dueDate : null
45
47
 
46
48
  try {
@@ -84,8 +86,8 @@ export default new Action({
84
86
  createdByUserId,
85
87
  dueDate,
86
88
  archived: false,
87
- createdAt: card.get('createdAt') ?? card.get('created_at') ?? null,
88
- updatedAt: card.get('updatedAt') ?? card.get('updated_at') ?? null,
89
+ createdAt: card.get('created_at') ?? null,
90
+ updatedAt: card.get('updated_at') ?? null,
89
91
  },
90
92
  }
91
93
  }
@@ -99,7 +99,7 @@ export default new Action({
99
99
  if (boardIds.size > 1) {
100
100
  return kanbanError('All columns in a reorder request must belong to the same board.', 400)
101
101
  }
102
- const boardId = colRows[0].board_id
102
+ const boardId = colRows[0]?.board_id
103
103
 
104
104
  // Verify all cards belong to that board (prevents a malformed
105
105
  // page from moving cards in from a sibling board).
@@ -1,6 +1,6 @@
1
- export interface KanbanModelRecord {
2
- get: (key: string) => unknown
3
- }
1
+ import type { ReadableRecord } from '@stacksjs/orm'
2
+ /** The shared shape, kept under this name for the helpers below. */
3
+ export type KanbanModelRecord = ReadableRecord
4
4
 
5
5
  export interface RefreshableKanbanModelRecord extends KanbanModelRecord {
6
6
  fresh: () => Promise<KanbanModelRecord | null>
@@ -1,6 +1,7 @@
1
+ import type { ResponseStatus } from '@stacksjs/bun-router'
1
2
  import { response } from '@stacksjs/router'
2
3
 
3
- export function kanbanError(error: string, status: number): Response {
4
+ export function kanbanError(error: string, status: ResponseStatus): Response {
4
5
  return response.json({ error }, status)
5
6
  }
6
7
 
@@ -44,6 +44,7 @@ function dependencySnapshot() {
44
44
 
45
45
  for (const wsKey of Object.keys(workspaces)) {
46
46
  const ws = workspaces[wsKey]
47
+ if (!ws) continue
47
48
  const allDeps = { ...ws.dependencies, ...ws.devDependencies }
48
49
  for (const depName of Object.keys(allDeps)) {
49
50
  if (declaredSet[depName]) continue
@@ -12,13 +12,18 @@ export default new Action({
12
12
  try {
13
13
  const allReleases = await Release.orderByDesc('id').limit(500).get()
14
14
 
15
+ // Only columns the Release model actually declares. `released_at`,
16
+ // `changes`, `change_count` and `breaking` were read here but have never
17
+ // existed on the model, so they resolved to '' / 0 / false on every row
18
+ // - the same fabricated-metric problem 9882e7ede removed from the rest
19
+ // of this dashboard, left behind in this one action. Nothing consumed
20
+ // them.
15
21
  const releases = allReleases.map(r => ({
16
22
  version: String(r.get('version') || ''),
17
- date: String(r.get('created_at') || r.get('released_at') || ''),
23
+ date: String(r.get('created_at') || ''),
18
24
  type: String(r.get('type') || 'patch'),
19
25
  status: String(r.get('status') || 'previous'),
20
- changes: Number(r.get('changes') || r.get('change_count') || 0),
21
- breaking: Boolean(r.get('breaking') || false),
26
+ notes: String(r.get('notes') || ''),
22
27
  }))
23
28
 
24
29
  return { releases }
@@ -28,9 +28,9 @@ export default new Action({
28
28
  ...data,
29
29
  audience_size: 0,
30
30
  sent_count: 0,
31
- open_rate: null,
32
- click_rate: null,
33
- conversion_rate: null,
31
+ // A campaign that has not been sent has no rates yet. The columns are
32
+ // nullable in the database but the model types them as optional
33
+ // numbers, so leaving them out says "not measured" in both.
34
34
  })
35
35
  return response.json({ id: campaign.get('id') }, 201)
36
36
  }
@@ -17,9 +17,16 @@ export default new Action({
17
17
  if (validationError)
18
18
  return response.json({ message: validationError }, 422)
19
19
 
20
+ // A social post belongs to a User, and the column is NOT NULL - so an
21
+ // author-less payload used to pass validation and then fail at the
22
+ // database as a 500. Say so plainly instead.
23
+ if (data.user_id === null)
24
+ return response.json({ message: 'A social post needs an author.' }, 422)
25
+
20
26
  try {
21
27
  const post = await SocialPost.create({
22
28
  ...data,
29
+ user_id: data.user_id,
23
30
  platform: data.platform as Exclude<typeof data.platform, ''>,
24
31
  likes: 0,
25
32
  shares: 0,
@@ -25,8 +25,12 @@ export default new Action({
25
25
  if (!post)
26
26
  return response.json({ message: 'Social post not found.' }, 404)
27
27
 
28
+ // Same NOT NULL author column as on create; an update that did not
29
+ // name one leaves the existing author alone rather than clearing it.
30
+ const { user_id: authorId, ...rest } = data
28
31
  await post.update({
29
- ...data,
32
+ ...rest,
33
+ ...(authorId === null ? {} : { user_id: authorId }),
30
34
  platform: data.platform as Exclude<typeof data.platform, ''>,
31
35
  })
32
36
  return response.json({ id })
@@ -84,7 +84,7 @@ function walkModels(dir: string, recursive: boolean): Array<{ name: string, file
84
84
  function categorize(model: { source: 'userland' | 'framework', file: string }): string {
85
85
  if (model.source === 'userland') return 'userland'
86
86
  const segs = model.file.split('/Models/')[1]?.split('/') ?? []
87
- if (segs.length > 1) return segs[0].toLowerCase()
87
+ if (segs.length > 1 && segs[0]) return segs[0].toLowerCase()
88
88
  return 'framework'
89
89
  }
90
90
 
@@ -197,19 +197,22 @@ export default new Action({
197
197
  const userlandCount = enriched.filter(m => m.source === 'userland').length
198
198
  const unavailableModels = enriched.filter(m => m.error !== null).length
199
199
 
200
- const groupMap: Record<string, ModelRow[]> = {}
200
+ const groupMap = new Map<string, ModelRow[]>()
201
201
  for (const m of enriched) {
202
- if (!groupMap[m.category]) groupMap[m.category] = []
203
- groupMap[m.category].push(m)
202
+ const bucket = groupMap.get(m.category) ?? []
203
+ bucket.push(m)
204
+ groupMap.set(m.category, bucket)
204
205
  }
205
206
 
206
- const categoryGroups: ModelGroup[] = Object.keys(groupMap)
207
- .sort((a, b) => categoryOrder(a) - categoryOrder(b))
208
- .map(key => ({
207
+ // A Map rather than a plain object: every read here is by a key that was
208
+ // just written, and an index signature makes the compiler doubt all four.
209
+ const categoryGroups: ModelGroup[] = [...groupMap.entries()]
210
+ .sort(([a], [b]) => categoryOrder(a) - categoryOrder(b))
211
+ .map(([key, models]) => ({
209
212
  key,
210
213
  label: categoryLabel(key),
211
- models: groupMap[key],
212
- countLabel: `${groupMap[key].length} model${groupMap[key].length === 1 ? '' : 's'}`,
214
+ models,
215
+ countLabel: `${models.length} model${models.length === 1 ? '' : 's'}`,
213
216
  }))
214
217
 
215
218
  return {
@@ -2,9 +2,12 @@ import type { RequestInstance } from '@stacksjs/types'
2
2
  import type {
3
3
  ControlPlaneActor,
4
4
  ControlPlaneOperation,
5
- DashboardControlPlane,
6
5
  JsonValue,
7
6
  } from '@stacksjs/ts-cloud'
7
+ // Lives on the `/deploy` entry alongside `initializeDashboardControlPlane`,
8
+ // not on the package root - importing it from the root silently produced
9
+ // `any`, and every caller of this helper inherited that.
10
+ import type { DashboardControlPlane } from '@stacksjs/ts-cloud/deploy'
8
11
  import process from 'node:process'
9
12
  import { setStateDir } from '@stacksjs/ts-cloud'
10
13
  import { initializeDashboardControlPlane } from '@stacksjs/ts-cloud/deploy'
@@ -48,7 +51,16 @@ export function operationsEnvironment(controlPlane = operationsControlPlane()) {
48
51
  ?? controlPlane.environments.values().next().value
49
52
  }
50
53
 
51
- export async function trackOperatorOperation<T extends JsonValue>(
54
+ /**
55
+ * `T` is only required to be serializable, not to BE a `JsonValue`.
56
+ *
57
+ * An interface without an index signature - `Alert`, `ReconcileResult` - never
58
+ * satisfies `{ [key: string]: JsonValue }` structurally, however plainly
59
+ * JSON-shaped its fields are. Constraining the result that way meant the
60
+ * honest callers were the ones that failed to typecheck, so the constraint is
61
+ * dropped here and the value is narrowed once, where it is actually recorded.
62
+ */
63
+ export async function trackOperatorOperation<T>(
52
64
  request: RequestInstance,
53
65
  kind: string,
54
66
  input: JsonValue,
@@ -83,7 +95,7 @@ export async function trackOperatorOperation<T extends JsonValue>(
83
95
  operation = controlPlane.store.transitionOperation(operation.id, {
84
96
  to: 'succeeded',
85
97
  expectedVersion: operation.version,
86
- output: result,
98
+ output: result as JsonValue,
87
99
  })
88
100
  controlPlane.store.appendEvent({
89
101
  organizationId: controlPlane.organization.id,
@@ -92,7 +104,7 @@ export async function trackOperatorOperation<T extends JsonValue>(
92
104
  actorId: actor.id,
93
105
  correlationId: operation.correlationId,
94
106
  type: `${kind}.succeeded`,
95
- payload: result,
107
+ payload: result as JsonValue,
96
108
  })
97
109
  return { result, operation }
98
110
  }
@@ -23,7 +23,7 @@ export default new Action({
23
23
  return {
24
24
  enabled: queryLogging?.enabled === true,
25
25
  slowThreshold: queryLogging?.slowThreshold || 100,
26
- queries: rows.map(row => mapDashboardQueryLog(row as QueryLogSourceRow)),
26
+ queries: rows.map((row: unknown) => mapDashboardQueryLog(row as QueryLogSourceRow)),
27
27
  }
28
28
  }
29
29
  catch (error) {