@stacksjs/defaults 0.70.355 → 0.70.357

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 (180) hide show
  1. package/ai/skills/stacks-email/SKILL.md +38 -1
  2. package/ai/skills/stacks-orm/SKILL.md +6 -0
  3. package/app/Actions/ContactAction.ts +2 -2
  4. package/app/Actions/Dashboard/Analytics/EventAnalyticsAction.ts +2 -3
  5. package/app/Actions/Dashboard/Analytics/MarketingAnalyticsAction.ts +2 -3
  6. package/app/Actions/Dashboard/Analytics/SalesAnalyticsAction.ts +2 -3
  7. package/app/Actions/Dashboard/Analytics/WebAnalyticsAction.ts +9 -3
  8. package/app/Actions/Dashboard/Auth/MeAction.ts +4 -7
  9. package/app/Actions/Dashboard/Buddy/BuddyChatAction.ts +2 -3
  10. package/app/Actions/Dashboard/Cloud/CloudIndexAction.ts +8 -1
  11. package/app/Actions/Dashboard/Cloud/ServerlessIndexAction.ts +15 -8
  12. package/app/Actions/Dashboard/Commerce/CommerceCategoriesAction.ts +2 -4
  13. package/app/Actions/Dashboard/Commerce/CommerceCouponsAction.ts +2 -4
  14. package/app/Actions/Dashboard/Commerce/CommerceCustomersAction.ts +2 -4
  15. package/app/Actions/Dashboard/Commerce/CommerceDashboardAction.ts +2 -4
  16. package/app/Actions/Dashboard/Commerce/CommerceDeliveryAction.ts +2 -4
  17. package/app/Actions/Dashboard/Commerce/CommerceGiftCardsAction.ts +2 -4
  18. package/app/Actions/Dashboard/Commerce/CommerceOrdersAction.ts +2 -4
  19. package/app/Actions/Dashboard/Commerce/CommercePaymentsAction.ts +2 -4
  20. package/app/Actions/Dashboard/Commerce/CommercePosAction.ts +2 -4
  21. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +75 -61
  22. package/app/Actions/Dashboard/Commerce/CommercePrintDevicesAction.ts +2 -4
  23. package/app/Actions/Dashboard/Commerce/CommercePrintLogsAction.ts +2 -4
  24. package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +3 -4
  25. package/app/Actions/Dashboard/Commerce/CommerceProductsAction.ts +2 -4
  26. package/app/Actions/Dashboard/Commerce/CommerceTaxesAction.ts +2 -4
  27. package/app/Actions/Dashboard/Commerce/DeliveryRouteIndexAction.ts +2 -4
  28. package/app/Actions/Dashboard/Commerce/DigitalDeliveryIndexAction.ts +2 -4
  29. package/app/Actions/Dashboard/Commerce/DriverIndexAction.ts +2 -4
  30. package/app/Actions/Dashboard/Commerce/LicenseKeyIndexAction.ts +2 -4
  31. package/app/Actions/Dashboard/Commerce/LicenseKeyOptionsAction.ts +2 -4
  32. package/app/Actions/Dashboard/Commerce/ManufacturerIndexAction.ts +2 -4
  33. package/app/Actions/Dashboard/Commerce/ProductUnitIndexAction.ts +2 -4
  34. package/app/Actions/Dashboard/Commerce/ProductVariantIndexAction.ts +2 -4
  35. package/app/Actions/Dashboard/Commerce/ProductWaitlistIndexAction.ts +2 -4
  36. package/app/Actions/Dashboard/Commerce/RestaurantWaitlistIndexAction.ts +2 -4
  37. package/app/Actions/Dashboard/Commerce/ReviewIndexAction.ts +2 -4
  38. package/app/Actions/Dashboard/Commerce/ShippingMethodIndexAction.ts +2 -4
  39. package/app/Actions/Dashboard/Commerce/ShippingRateIndexAction.ts +2 -4
  40. package/app/Actions/Dashboard/Commerce/ShippingZoneIndexAction.ts +2 -4
  41. package/app/Actions/Dashboard/Content/AuthorDestroyAction.ts +23 -9
  42. package/app/Actions/Dashboard/Content/AuthorIndexAction.ts +32 -26
  43. package/app/Actions/Dashboard/Content/AuthorStoreAction.ts +44 -24
  44. package/app/Actions/Dashboard/Content/AuthorUpdateAction.ts +48 -26
  45. package/app/Actions/Dashboard/Content/CategoryDestroyAction.ts +27 -8
  46. package/app/Actions/Dashboard/Content/CategoryIndexAction.ts +21 -15
  47. package/app/Actions/Dashboard/Content/CategoryStoreAction.ts +47 -29
  48. package/app/Actions/Dashboard/Content/CommentDestroyAction.ts +17 -4
  49. package/app/Actions/Dashboard/Content/CommentIndexAction.ts +23 -17
  50. package/app/Actions/Dashboard/Content/CommentUpdateAction.ts +30 -12
  51. package/app/Actions/Dashboard/Content/ContentDashboardAction.ts +43 -37
  52. package/app/Actions/Dashboard/Content/PageDestroyAction.ts +17 -4
  53. package/app/Actions/Dashboard/Content/PageIndexAction.ts +23 -17
  54. package/app/Actions/Dashboard/Content/PageStoreAction.ts +33 -18
  55. package/app/Actions/Dashboard/Content/PageUpdateAction.ts +40 -22
  56. package/app/Actions/Dashboard/Content/PostDestroyAction.ts +24 -11
  57. package/app/Actions/Dashboard/Content/PostIndexAction.ts +55 -49
  58. package/app/Actions/Dashboard/Content/PostStoreAction.ts +43 -33
  59. package/app/Actions/Dashboard/Content/PostUpdateAction.ts +46 -34
  60. package/app/Actions/Dashboard/Content/TagDestroyAction.ts +27 -8
  61. package/app/Actions/Dashboard/Content/TagIndexAction.ts +31 -25
  62. package/app/Actions/Dashboard/Content/TagStoreAction.ts +44 -23
  63. package/app/Actions/Dashboard/Content/content-input.ts +4 -4
  64. package/app/Actions/Dashboard/Content/post-input.ts +60 -8
  65. package/app/Actions/Dashboard/DashboardActivityAction.ts +2 -5
  66. package/app/Actions/Dashboard/Data/ActivityIndexAction.ts +2 -5
  67. package/app/Actions/Dashboard/Data/SubscriberIndexAction.ts +2 -5
  68. package/app/Actions/Dashboard/Data/TeamIndexAction.ts +2 -5
  69. package/app/Actions/Dashboard/Data/UserIndexAction.ts +2 -5
  70. package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +23 -19
  71. package/app/Actions/Dashboard/Deployments/GetAverageDeploymentTime.ts +8 -2
  72. package/app/Actions/Dashboard/Deployments/GetDeployScript.ts +3 -6
  73. package/app/Actions/Dashboard/Deployments/GetDeployment.ts +4 -5
  74. package/app/Actions/Dashboard/Deployments/GetDeploymentCount.ts +7 -1
  75. package/app/Actions/Dashboard/Deployments/GetDeploymentLiveTerminalOutput.ts +10 -9
  76. package/app/Actions/Dashboard/Deployments/GetDeployments.ts +2 -4
  77. package/app/Actions/Dashboard/Deployments/GetRecentDeployments.ts +8 -2
  78. package/app/Actions/Dashboard/Deployments/UpdateDeployScript.ts +18 -5
  79. package/app/Actions/Dashboard/Email/CapturedMailIndexAction.ts +2 -4
  80. package/app/Actions/Dashboard/Email/CapturedMailShowAction.ts +3 -4
  81. package/app/Actions/Dashboard/Infrastructure/InsightsAction.ts +15 -14
  82. package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +2 -8
  83. package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +2 -4
  84. package/app/Actions/Dashboard/Jobs/JobShowAction.ts +13 -8
  85. package/app/Actions/Dashboard/Jobs/JobStatsAction.ts +2 -4
  86. package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +2 -3
  87. package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +2 -3
  88. package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +2 -3
  89. package/app/Actions/Dashboard/Kanban/BoardUpdateAction.ts +2 -3
  90. package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +2 -3
  91. package/app/Actions/Dashboard/Kanban/BoardsReorderAction.ts +2 -3
  92. package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +2 -3
  93. package/app/Actions/Dashboard/Kanban/CardCommentDestroyAction.ts +2 -3
  94. package/app/Actions/Dashboard/Kanban/CardCommentStoreAction.ts +2 -3
  95. package/app/Actions/Dashboard/Kanban/CardCommentUpdateAction.ts +2 -3
  96. package/app/Actions/Dashboard/Kanban/CardDestroyAction.ts +2 -3
  97. package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +2 -3
  98. package/app/Actions/Dashboard/Kanban/CardShowAction.ts +2 -3
  99. package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +2 -3
  100. package/app/Actions/Dashboard/Kanban/CardUpdateAction.ts +2 -3
  101. package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +2 -3
  102. package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +2 -3
  103. package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +2 -3
  104. package/app/Actions/Dashboard/Kanban/ColumnUpdateAction.ts +2 -3
  105. package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +2 -3
  106. package/app/Actions/Dashboard/Kanban/LabelDestroyAction.ts +2 -3
  107. package/app/Actions/Dashboard/Kanban/LabelStoreAction.ts +2 -3
  108. package/app/Actions/Dashboard/Kanban/LabelUpdateAction.ts +2 -3
  109. package/app/Actions/Dashboard/Kanban/UsersListAction.ts +2 -3
  110. package/app/Actions/Dashboard/Kanban/kanban-response.ts +5 -0
  111. package/app/Actions/Dashboard/Library/CreateComponent.ts +15 -6
  112. package/app/Actions/Dashboard/Library/CreateFunction.ts +15 -6
  113. package/app/Actions/Dashboard/Library/DependencyIndexAction.ts +88 -77
  114. package/app/Actions/Dashboard/Library/GetAverageReleaseTime.ts +21 -15
  115. package/app/Actions/Dashboard/Library/GetComponents.ts +14 -7
  116. package/app/Actions/Dashboard/Library/GetDownloadCount.ts +14 -5
  117. package/app/Actions/Dashboard/Library/GetFunctions.ts +12 -5
  118. package/app/Actions/Dashboard/Library/GetReleaseCount.ts +14 -5
  119. package/app/Actions/Dashboard/Library/GetReleases.ts +17 -10
  120. package/app/Actions/Dashboard/Library/PackageIndexAction.ts +14 -7
  121. package/app/Actions/Dashboard/Models/ModelDestroyAction.ts +7 -5
  122. package/app/Actions/Dashboard/Models/ModelShowAction.ts +82 -174
  123. package/app/Actions/Dashboard/Models/ModelStoreAction.ts +6 -4
  124. package/app/Actions/Dashboard/Models/ModelUpdateAction.ts +9 -7
  125. package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +47 -17
  126. package/app/Actions/Dashboard/Models/model-write.ts +20 -5
  127. package/app/Actions/Dashboard/Notifications/NotificationDeliveryHistoryAction.ts +46 -36
  128. package/app/Actions/Dashboard/Notifications/NotificationDeliveryIndexAction.ts +14 -6
  129. package/app/Actions/Dashboard/Notifications/NotificationDeliveryOverviewAction.ts +96 -84
  130. package/app/Actions/Dashboard/Notifications/NotificationDeliveryRetryAction.ts +34 -21
  131. package/app/Actions/Dashboard/Queries/QueryDashboardAction.ts +2 -4
  132. package/app/Actions/Dashboard/Queries/QueryShowAction.ts +2 -3
  133. package/app/Actions/Dashboard/Queue/QueueStatsAction.ts +2 -4
  134. package/app/Actions/Dashboard/Queue/QueueWorkersAction.ts +2 -4
  135. package/app/Actions/Dashboard/Rbac/PermissionDestroyAction.ts +2 -2
  136. package/app/Actions/Dashboard/Rbac/PermissionStoreAction.ts +2 -2
  137. package/app/Actions/Dashboard/Rbac/PermissionsIndexAction.ts +2 -3
  138. package/app/Actions/Dashboard/Rbac/RoleDestroyAction.ts +2 -2
  139. package/app/Actions/Dashboard/Rbac/RolePermissionsShowAction.ts +2 -2
  140. package/app/Actions/Dashboard/Rbac/RolePermissionsSyncAction.ts +2 -6
  141. package/app/Actions/Dashboard/Rbac/RoleStoreAction.ts +2 -2
  142. package/app/Actions/Dashboard/Rbac/RolesIndexAction.ts +4 -6
  143. package/app/Actions/Dashboard/Rbac/UserRolesShowAction.ts +2 -2
  144. package/app/Actions/Dashboard/Rbac/UserRolesSyncAction.ts +2 -9
  145. package/app/Actions/Dashboard/Rbac/UsersListAction.ts +2 -3
  146. package/app/Actions/Dashboard/Rbac/rbac-response.ts +10 -0
  147. package/app/Actions/Dashboard/Realtime/RealtimeStatsAction.ts +2 -4
  148. package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +68 -55
  149. package/app/Actions/Dashboard/Teams/TeamInvitationDestroyAction.ts +34 -16
  150. package/app/Actions/Dashboard/Teams/TeamInvitationResendAction.ts +52 -29
  151. package/app/Actions/Dashboard/Teams/TeamInviteAction.ts +102 -71
  152. package/app/Actions/Dashboard/Teams/TeamMemberDestroyAction.ts +36 -17
  153. package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +39 -26
  154. package/app/Actions/Dashboard/Teams/TeamPeopleIndexAction.ts +71 -58
  155. package/app/Actions/Dashboard/Teams/team-invitation-delivery.ts +29 -7
  156. package/app/Actions/Dashboard/Teams/team-member-count.ts +4 -2
  157. package/app/Actions/Dashboard/Teams/team-records.test.ts +2 -0
  158. package/app/Actions/Dashboard/Teams/team-records.ts +2 -0
  159. package/app/Actions/Dashboard/dashboard-response.ts +16 -0
  160. package/app/Actions/SendWelcomeEmail.ts +1 -1
  161. package/app/Actions/Teams/AcceptInvitationAction.ts +74 -51
  162. package/app/Actions/Teams/ShowInvitationAction.ts +21 -14
  163. package/app/Actions/Teams/team-response.ts +23 -0
  164. package/app/Mail/OrderConfirmation.ts +1 -1
  165. package/app/Mail/SubscriptionConfirmation.ts +1 -1
  166. package/app/Mail/TeamInvitation.ts +1 -1
  167. package/app/Models/EmailIdempotency.ts +64 -0
  168. package/app/Models/EmailSuppression.ts +52 -0
  169. package/app/Models/EmailWebhookEvent.ts +51 -0
  170. package/app/Models/TeamInvitation.ts +13 -0
  171. package/functions/data.ts +0 -1
  172. package/functions/jobs.ts +0 -1
  173. package/ide/vscode/package.json +1 -1
  174. package/package.json +1 -1
  175. package/resources/components/Dashboard/Data/DataRecords.stx +0 -4
  176. package/resources/components/Dashboard/Jobs/JobDetail.stx +3 -2
  177. package/resources/components/Dashboard/Models/ModelRecordsDashboard.stx +7 -13
  178. package/resources/components/Dashboard/Models/ModelsOverview.stx +1 -1
  179. package/views/dashboard/.discovered-models.json +30 -0
  180. package/views/dashboard/stores/auth.ts +15 -14
@@ -1,18 +1,25 @@
1
1
  import { Action } from '@stacksjs/actions'
2
+ import { dashboardOperationalError } from '../dashboard-response'
2
3
  import { functionSourceRows } from './library-source'
3
4
 
4
5
  export default new Action({
5
6
  name: 'GetFunctions',
6
7
  description: 'Gets your utility functions.',
7
8
  method: 'GET',
9
+ apiResponse: true,
8
10
 
9
11
  async handle() {
10
- const functions = functionSourceRows()
12
+ try {
13
+ const functions = functionSourceRows()
11
14
 
12
- return {
13
- functions,
14
- totalBytes: functions.reduce((sum, fn) => sum + fn.bytes, 0),
15
- sourceRoot: 'resources/functions',
15
+ return {
16
+ functions,
17
+ totalBytes: functions.reduce((sum, fn) => sum + fn.bytes, 0),
18
+ sourceRoot: 'resources/functions',
19
+ }
20
+ }
21
+ catch (error) {
22
+ return dashboardOperationalError(error, 'Function library could not be loaded.', 'GetFunctions')
16
23
  }
17
24
  },
18
25
  })
@@ -1,5 +1,6 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { db } from '@stacksjs/database'
3
+ import { dashboardOperationalError } from '../dashboard-response'
3
4
 
4
5
  export default new Action({
5
6
  name: 'GetReleaseCount',
@@ -7,11 +8,19 @@ export default new Action({
7
8
  apiResponse: true,
8
9
 
9
10
  async handle() {
10
- const row = await db
11
- .selectFrom('releases')
12
- .select(db.fn.count('id').as('count'))
13
- .executeTakeFirst() as { count?: number | string } | undefined
11
+ try {
12
+ const row = await db
13
+ .selectFrom('releases')
14
+ .select(db.fn.count('id').as('count'))
15
+ .executeTakeFirst() as { count?: number | string } | undefined
16
+ const count = Number(row?.count || 0)
17
+ if (!Number.isSafeInteger(count) || count < 0)
18
+ throw new TypeError('Release count must be a non-negative integer.')
14
19
 
15
- return { count: Number(row?.count || 0) }
20
+ return { count }
21
+ }
22
+ catch (error) {
23
+ return dashboardOperationalError(error, 'Release count could not be loaded.', 'GetReleaseCount')
24
+ }
16
25
  },
17
26
  })
@@ -1,23 +1,30 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { Release } from '@stacksjs/orm'
3
+ import { dashboardOperationalError } from '../dashboard-response'
3
4
 
4
5
  export default new Action({
5
6
  name: 'GetReleases',
6
7
  description: 'Gets your releases.',
7
8
  method: 'GET',
9
+ apiResponse: true,
8
10
 
9
11
  async handle() {
10
- const allReleases = await Release.orderByDesc('id').get()
12
+ try {
13
+ const allReleases = await Release.orderByDesc('id').limit(500).get()
11
14
 
12
- const releases = allReleases.map(r => ({
13
- version: String(r.get('version') || ''),
14
- date: String(r.get('created_at') || r.get('released_at') || ''),
15
- type: String(r.get('type') || 'patch'),
16
- status: String(r.get('status') || 'previous'),
17
- changes: Number(r.get('changes') || r.get('change_count') || 0),
18
- breaking: Boolean(r.get('breaking') || false),
19
- }))
15
+ const releases = allReleases.map(r => ({
16
+ version: String(r.get('version') || ''),
17
+ date: String(r.get('created_at') || r.get('released_at') || ''),
18
+ type: String(r.get('type') || 'patch'),
19
+ status: String(r.get('status') || 'previous'),
20
+ changes: Number(r.get('changes') || r.get('change_count') || 0),
21
+ breaking: Boolean(r.get('breaking') || false),
22
+ }))
20
23
 
21
- return { releases }
24
+ return { releases }
25
+ }
26
+ catch (error) {
27
+ return dashboardOperationalError(error, 'Releases could not be loaded.', 'GetReleases')
28
+ }
22
29
  },
23
30
  })
@@ -1,19 +1,26 @@
1
1
  import { Action } from '@stacksjs/actions'
2
+ import { dashboardOperationalError } from '../dashboard-response'
2
3
  import { workspacePackageRows } from './library-source'
3
4
 
4
5
  export default new Action({
5
6
  name: 'PackageIndexAction',
6
7
  description: 'Returns package data for the dashboard.',
7
8
  method: 'GET',
9
+ apiResponse: true,
8
10
  async handle() {
9
- const packages = workspacePackageRows()
11
+ try {
12
+ const packages = workspacePackageRows()
10
13
 
11
- return {
12
- packages,
13
- publicCount: packages.filter(pkg => !pkg.private).length,
14
- privateCount: packages.filter(pkg => pkg.private).length,
15
- dependencyCount: packages.reduce((sum, pkg) => sum + pkg.dependencyCount, 0),
16
- source: 'bun.lock',
14
+ return {
15
+ packages,
16
+ publicCount: packages.filter(pkg => !pkg.private).length,
17
+ privateCount: packages.filter(pkg => pkg.private).length,
18
+ dependencyCount: packages.reduce((sum, pkg) => sum + pkg.dependencyCount, 0),
19
+ source: 'bun.lock',
20
+ }
21
+ }
22
+ catch (error) {
23
+ return dashboardOperationalError(error, 'Workspace packages could not be loaded.', 'PackageIndexAction')
17
24
  }
18
25
  },
19
26
  })
@@ -1,5 +1,7 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+ import { dashboardOperationalError } from '../dashboard-response'
3
5
  import { parseRowId, resolveWritableModel } from './model-write'
4
6
 
5
7
  /**
@@ -17,21 +19,21 @@ export default new Action({
17
19
  async handle(request: RequestInstance) {
18
20
  const resolved = await resolveWritableModel(request.getParam('slug'), 'destroy')
19
21
  if ('error' in resolved)
20
- return { ok: false, error: resolved.error }
22
+ return response.json({ message: resolved.error }, resolved.status)
21
23
 
22
24
  const id = parseRowId(request.getParam('id'))
23
25
  if (id === null)
24
- return { ok: false, error: 'A numeric row id is required.' }
26
+ return response.json({ message: 'A numeric row id is required.' }, 400)
25
27
 
26
28
  try {
27
29
  const row = await resolved.Model.find(id)
28
30
  if (!row)
29
- return { ok: false, error: `${resolved.modelName} ${id} not found.` }
31
+ return response.json({ message: `${resolved.modelName} ${id} not found.` }, 404)
30
32
  await row.delete()
31
33
  return { ok: true, id }
32
34
  }
33
- catch (e) {
34
- return { ok: false, error: e instanceof Error ? e.message : String(e) }
35
+ catch (error) {
36
+ return dashboardOperationalError(error, 'The model record could not be deleted.', 'ModelDestroyAction', 500)
35
37
  }
36
38
  },
37
39
  })
@@ -1,8 +1,8 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
- import { env } from '@stacksjs/env'
4
3
  import { response as routerResponse } from '@stacksjs/router'
5
4
  import { loadModelIfExists, safeGet } from '../../../../resources/functions/dashboard/data'
5
+ import { dashboardOperationalError } from '../dashboard-response'
6
6
  import { isValidModelSlug, type ModelCreateField, type ModelWriteCapabilities, modelCreateFields, modelSchemaColumns, modelWriteCapabilities, slugToPascal } from './model-write'
7
7
 
8
8
  /**
@@ -23,8 +23,8 @@ import { isValidModelSlug, type ModelCreateField, type ModelWriteCapabilities, m
23
23
  * queries, so scopes, casts and accessors all apply.
24
24
  * 2. `loadModelIfExists(Name)` — path-map lookup, covers models the ORM has not
25
25
  * registered as a global.
26
- * 3. Raw SQLite last resort for a table with no model file at all
27
- * (lookup/pivot tables, or a schema added outside the ORM).
26
+ * A missing model is a 404. The generic browser never bypasses model scopes,
27
+ * casts, observers, connection drivers, or the app override contract.
28
28
  */
29
29
 
30
30
  interface ColumnMeta {
@@ -50,8 +50,6 @@ interface ResponseShape {
50
50
  dir: 'asc' | 'desc'
51
51
  q: string
52
52
  filters: Record<string, string>
53
- source: 'orm' | 'sqlite-fallback'
54
- /** False for tables reached through the SQLite fallback: no model, no writes. */
55
53
  writable: boolean
56
54
  writeCapabilities: ModelWriteCapabilities
57
55
  createFields: ModelCreateField[]
@@ -85,6 +83,15 @@ function humanize(column: string): string {
85
83
  // filter by it.
86
84
  const HIDDEN_COLUMNS = new Set(['password', 'remember_token', 'api_token', 'access_token', 'refresh_token', 'secret', 'two_factor_secret'])
87
85
 
86
+ function hiddenModelColumns(Model: any): Set<string> {
87
+ const hidden = new Set(HIDDEN_COLUMNS)
88
+ for (const [name, definition] of Object.entries(Model?.attributes ?? {})) {
89
+ if ((definition as { hidden?: boolean })?.hidden)
90
+ hidden.add(pascalToSnake(name))
91
+ }
92
+ return hidden
93
+ }
94
+
88
95
  /**
89
96
  * Column identifiers reach the query builder as raw, unquoted SQL, so only
90
97
  * valid identifiers from the table's own column list may ever be used.
@@ -151,15 +158,6 @@ export function parseFilters(raw: string | null): Record<string, string> {
151
158
  return out
152
159
  }
153
160
 
154
- /**
155
- * Signals that a free-text search spans several columns, which the query
156
- * builder cannot express (`whereLike` is single-column and `orWhere` only
157
- * takes equality pairs). The SQLite path builds that OR with bound
158
- * parameters instead. Distinct from a real failure so it never surfaces
159
- * to the page as an error.
160
- */
161
- class SearchUnsupported extends Error {}
162
-
163
161
  function resolveOrmModel(modelName: string): any {
164
162
  const injected = (globalThis as Record<string, any>)[modelName]
165
163
  // The ORM injects Proxies that answer `undefined` for every property
@@ -213,182 +211,92 @@ export default new Action({
213
211
  dir,
214
212
  q,
215
213
  filters,
216
- source: 'orm',
217
214
  writable: false,
218
215
  writeCapabilities: { create: false, update: false, destroy: false },
219
216
  createFields: [],
220
217
  error: null,
221
218
  }
222
219
 
223
- const Model = resolveOrmModel(modelName) ?? await loadModelIfExists(modelName)
224
- const hasOrm = Boolean(Model) && typeof Model.where === 'function'
225
-
226
- if (hasOrm) {
227
- try {
228
- response.tableName = String(Model.table || tableName)
229
- // The column list has to come from a real row: the ORM does not
230
- // expose a database schema reflection API. Empty tables fall back
231
- // to the model definition, which is also the migration source.
232
- const probe = await Model.orderByDesc('id').take(1).get() as Array<Record<string, unknown>>
233
- const first = Array.isArray(probe) ? probe[0] : undefined
234
- const shape = first && typeof first === 'object'
235
- ? ((first as { attributes?: Record<string, unknown> }).attributes ?? first)
236
- : {}
237
- response.columns = Object.keys(shape).filter(k => typeof k === 'string' && !k.startsWith('_'))
238
- if (response.columns.length === 0)
239
- response.columns = modelSchemaColumns(Model)
240
-
241
- const allowed = new Set(response.columns.filter(c => !HIDDEN_COLUMNS.has(c)))
242
- const unknownFilter = Object.keys(filters).find(column => !isSafeColumn(column, allowed))
243
- if (unknownFilter)
244
- return routerResponse.json({ message: `Unknown model filter column "${unknownFilter}".` }, 422)
245
- if (requestedSort && !isSafeColumn(requestedSort, allowed))
246
- return routerResponse.json({ message: `Unknown model sort column "${requestedSort}".` }, 422)
247
-
248
- const sort = isSafeColumn(requestedSort, allowed)
249
- ? requestedSort
250
- : (allowed.has('id') ? 'id' : (response.columns[0] ?? ''))
251
- response.sort = sort
252
-
253
- // Text columns are decided from the probe row, before any query is
254
- // built, so the search clause does not depend on response metadata
255
- // that is only assembled after the rows come back.
256
- const searchColumns = [...allowed].filter(c => typeof safeGet(first, c, null) === 'string')
257
-
258
- const build = () => {
259
- let chain = Model as any
260
- for (const [column, value] of Object.entries(filters)) {
261
- if (!isSafeColumn(column, allowed)) continue
262
- chain = chain.where(column, value)
263
- }
264
- // Global search is a LIKE across every text column. The builder's
265
- // `whereLike` covers one column and `orWhere` only takes equality
266
- // pairs, so a multi-column OR-LIKE has no ORM expression; that
267
- // case is served by the SQLite path below instead, which builds
268
- // the OR with bound parameters. Writes stay enabled either way,
269
- // because the model still exists.
270
- if (q && searchColumns.length === 1)
271
- chain = chain.whereLike(searchColumns[0], `%${q}%`)
272
- return chain
273
- }
274
-
275
- // A multi-column search has no ORM expression, so hand that one
276
- // query to the SQLite path — without recording an error, because it
277
- // is a gap in the builder's vocabulary, not a failure.
278
- if (q && searchColumns.length > 1)
279
- throw new SearchUnsupported()
280
-
281
- response.totalCount = q && searchColumns.length === 0
282
- ? 0
283
- : await build().count()
284
- const rows = response.totalCount === 0
285
- ? []
286
- : await build()
287
- .orderBy(sort, dir)
288
- .skip((page - 1) * perPage)
289
- .take(perPage)
290
- .get() as Array<Record<string, unknown>>
291
-
292
- // Flatten to plain JSON-safe objects — proxy models and
293
- // accessor-rich rows do not serialise cleanly across the wire.
294
- response.rows = (Array.isArray(rows) ? rows : []).map((row) => {
295
- const flat: Record<string, unknown> = {}
296
- for (const col of response.columns)
297
- flat[col] = safeGet(row, col, null)
298
- return flat
299
- })
300
- }
301
- catch (e) {
302
- if (e instanceof SearchUnsupported) {
303
- response.source = 'sqlite-fallback'
304
- }
305
- else {
306
- return routerResponse.json({
307
- message: `Could not query model ${modelName}: ${e instanceof Error ? e.message : String(e)}`,
308
- }, 503)
309
- }
310
- }
220
+ let Model: any
221
+ try {
222
+ Model = resolveOrmModel(modelName) ?? await loadModelIfExists(modelName)
311
223
  }
312
- else {
313
- response.source = 'sqlite-fallback'
224
+ catch (error) {
225
+ return dashboardOperationalError(error, `${modelName} could not be loaded.`, 'ModelShowAction.resolve')
314
226
  }
227
+ if (!Model || typeof Model.where !== 'function')
228
+ return routerResponse.json({ message: `No ORM model named ${modelName}.` }, 404)
315
229
 
316
- if (response.source === 'sqlite-fallback') {
317
- try {
318
- const { Database } = await import('bun:sqlite')
319
- const db = new Database(env.DB_DATABASE_PATH || 'database/stacks.sqlite', { readonly: true })
320
- try {
321
- if (!/^[A-Za-z_]\w*$/.test(response.tableName))
322
- return routerResponse.json({ message: `Model table "${response.tableName}" is not a safe SQL identifier.` }, 500)
323
-
324
- const tableInfo = db.query(`PRAGMA table_info(${response.tableName})`).all() as Array<{ name: string, type: string }>
325
- if (tableInfo.length === 0)
326
- return routerResponse.json({ message: `Model table "${response.tableName}" does not exist.` }, 404)
327
-
328
- response.columns = tableInfo.map(c => c.name)
329
-
330
- if (response.columns.length > 0) {
331
- const allowed = new Set(response.columns.filter(c => !HIDDEN_COLUMNS.has(c)))
332
- const unknownFilter = Object.keys(filters).find(column => !isSafeColumn(column, allowed))
333
- if (unknownFilter)
334
- return routerResponse.json({ message: `Unknown model filter column "${unknownFilter}".` }, 422)
335
- if (requestedSort && !isSafeColumn(requestedSort, allowed))
336
- return routerResponse.json({ message: `Unknown model sort column "${requestedSort}".` }, 422)
337
-
338
- const sort = isSafeColumn(requestedSort, allowed)
339
- ? requestedSort
340
- : (allowed.has('id') ? 'id' : response.columns[0])
341
- response.sort = sort
342
-
343
- // Every value is bound; only identifiers are interpolated, and
344
- // those come from the allowlist built out of PRAGMA above.
345
- const wheres: string[] = []
346
- const values: unknown[] = []
347
- for (const [column, value] of Object.entries(filters)) {
348
- if (!isSafeColumn(column, allowed)) continue
349
- wheres.push(`${column} = ?`)
350
- values.push(value)
351
- }
352
- if (q) {
353
- const textColumns = tableInfo
354
- .filter(c => allowed.has(c.name) && /char|text|clob/i.test(c.type || ''))
355
- .map(c => c.name)
356
- if (textColumns.length > 0) {
357
- wheres.push(`(${textColumns.map(c => `${c} LIKE ?`).join(' OR ')})`)
358
- for (const _ of textColumns) values.push(`%${q}%`)
359
- }
360
- }
361
- const whereSql = wheres.length > 0 ? ` WHERE ${wheres.join(' AND ')}` : ''
362
-
363
- const countRow = db.query(`SELECT COUNT(*) as count FROM ${response.tableName}${whereSql}`).get(...values as any[]) as { count?: number } | null
364
- response.totalCount = countRow?.count ?? 0
365
- response.rows = db
366
- .query(`SELECT * FROM ${response.tableName}${whereSql} ORDER BY ${sort} ${dir.toUpperCase()} LIMIT ? OFFSET ?`)
367
- .all(...values as any[], perPage, (page - 1) * perPage) as Array<Record<string, unknown>>
368
- response.error = null
369
- }
230
+ try {
231
+ response.tableName = String(Model.table || tableName)
232
+ const probe = await Model.orderByDesc('id').take(1).get() as Array<Record<string, unknown>>
233
+ const first = Array.isArray(probe) ? probe[0] : undefined
234
+ const shape = first && typeof first === 'object'
235
+ ? ((first as { attributes?: Record<string, unknown> }).attributes ?? first)
236
+ : {}
237
+ const hiddenColumns = hiddenModelColumns(Model)
238
+ response.columns = Object.keys(shape).filter(k => typeof k === 'string' && !k.startsWith('_') && !hiddenColumns.has(k))
239
+ if (response.columns.length === 0)
240
+ response.columns = modelSchemaColumns(Model).filter(column => !hiddenColumns.has(column))
241
+
242
+ const allowed = new Set(response.columns)
243
+ const unknownFilter = Object.keys(filters).find(column => !isSafeColumn(column, allowed))
244
+ if (unknownFilter)
245
+ return routerResponse.json({ message: `Unknown model filter column "${unknownFilter}".` }, 422)
246
+ if (requestedSort && !isSafeColumn(requestedSort, allowed))
247
+ return routerResponse.json({ message: `Unknown model sort column "${requestedSort}".` }, 422)
248
+
249
+ const sort = isSafeColumn(requestedSort, allowed)
250
+ ? requestedSort
251
+ : (allowed.has('id') ? 'id' : (response.columns[0] ?? ''))
252
+ response.sort = sort
253
+ const searchColumns = [...allowed].filter(c => typeof safeGet(first, c, null) === 'string')
254
+
255
+ const build = () => {
256
+ let chain = Model.query() as any
257
+ for (const [column, value] of Object.entries(filters)) {
258
+ if (!isSafeColumn(column, allowed)) continue
259
+ chain = chain.where(column, value)
370
260
  }
371
- finally {
372
- db.close()
261
+ if (q && searchColumns.length > 0) {
262
+ chain = chain.whereGroup((group: any) => {
263
+ for (const [index, column] of searchColumns.entries()) {
264
+ group = index === 0
265
+ ? group.whereLike(column, `%${q}%`)
266
+ : group.orWhereLike(column, `%${q}%`)
267
+ }
268
+ return group
269
+ })
373
270
  }
271
+ return chain
374
272
  }
375
- catch (e) {
376
- return routerResponse.json({
377
- message: `Could not query table ${response.tableName}: ${e instanceof Error ? e.message : String(e)}`,
378
- }, 503)
379
- }
273
+
274
+ response.totalCount = q && searchColumns.length === 0
275
+ ? 0
276
+ : await build().count()
277
+ const rows = response.totalCount === 0
278
+ ? []
279
+ : await build()
280
+ .orderBy(sort, dir)
281
+ .skip((page - 1) * perPage)
282
+ .take(perPage)
283
+ .get() as Array<Record<string, unknown>>
284
+
285
+ response.rows = (Array.isArray(rows) ? rows : []).map((row) => {
286
+ const flat: Record<string, unknown> = {}
287
+ for (const col of response.columns)
288
+ flat[col] = safeGet(row, col, null)
289
+ return flat
290
+ })
291
+ }
292
+ catch (error) {
293
+ return dashboardOperationalError(error, `${modelName} records could not be loaded.`, 'ModelShowAction.query')
380
294
  }
381
295
 
382
- // Writability follows the model's declared useApi routes, not which
383
- // engine answered this particular query. A multi-column search can be
384
- // served by SQLite for a model that still creates and updates through
385
- // the ORM.
386
- response.writeCapabilities = hasOrm
387
- ? modelWriteCapabilities(Model)
388
- : { create: false, update: false, destroy: false }
296
+ response.writeCapabilities = modelWriteCapabilities(Model)
389
297
  response.writable = Object.values(response.writeCapabilities).some(Boolean)
390
298
  response.createFields = response.writeCapabilities.create ? modelCreateFields(Model) : []
391
- response.displayColumns = response.columns.filter(col => !HIDDEN_COLUMNS.has(col) && !col.startsWith('_'))
299
+ response.displayColumns = response.columns.filter(col => !col.startsWith('_'))
392
300
  response.columnMeta = response.displayColumns.map(name => ({
393
301
  name,
394
302
  label: humanize(name),
@@ -1,6 +1,8 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { toSnakeCaseKeys } from '@stacksjs/orm'
4
+ import { response } from '@stacksjs/router'
5
+ import { dashboardOperationalError } from '../dashboard-response'
4
6
  import { prepareModelFields, resolveWritableModel } from './model-write'
5
7
 
6
8
  interface ModelWriteInput {
@@ -15,7 +17,7 @@ export default new Action({
15
17
  async handle(request: RequestInstance<ModelWriteInput>) {
16
18
  const resolved = await resolveWritableModel(request.getParam('slug'), 'store')
17
19
  if ('error' in resolved)
18
- return { ok: false, error: resolved.error }
20
+ return response.json({ message: resolved.error }, resolved.status)
19
21
 
20
22
  const input = request.all()
21
23
  const raw = input.fields
@@ -23,9 +25,9 @@ export default new Action({
23
25
  const { data, errors } = prepareModelFields(resolved.Model, body)
24
26
 
25
27
  if (Object.keys(errors).length > 0)
26
- return { ok: false, error: 'Validation failed.', errors }
28
+ return response.json({ message: 'Validation failed.', errors }, 422)
27
29
  if (Object.keys(data).length === 0)
28
- return { ok: false, error: 'No fillable fields in the request body.' }
30
+ return response.json({ message: 'No fillable fields in the request body.' }, 422)
29
31
 
30
32
  try {
31
33
  const row = await resolved.Model.create(toSnakeCaseKeys(data))
@@ -33,7 +35,7 @@ export default new Action({
33
35
  return { ok: true, id }
34
36
  }
35
37
  catch (error) {
36
- return { ok: false, error: error instanceof Error ? error.message : String(error) }
38
+ return dashboardOperationalError(error, 'The model record could not be created.', 'ModelStoreAction', 500)
37
39
  }
38
40
  },
39
41
  })
@@ -1,6 +1,8 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { toSnakeCaseKeys } from '@stacksjs/orm'
4
+ import { response } from '@stacksjs/router'
5
+ import { dashboardOperationalError } from '../dashboard-response'
4
6
  import { parseRowId, prepareModelFields, resolveWritableModel } from './model-write'
5
7
 
6
8
  interface ModelWriteInput {
@@ -23,11 +25,11 @@ export default new Action({
23
25
  async handle(request: RequestInstance<ModelWriteInput>) {
24
26
  const resolved = await resolveWritableModel(request.getParam('slug'), 'update')
25
27
  if ('error' in resolved)
26
- return { ok: false, error: resolved.error }
28
+ return response.json({ message: resolved.error }, resolved.status)
27
29
 
28
30
  const id = parseRowId(request.getParam('id'))
29
31
  if (id === null)
30
- return { ok: false, error: 'A numeric row id is required.' }
32
+ return response.json({ message: 'A numeric row id is required.' }, 400)
31
33
 
32
34
  // Edits arrive nested under `fields`. The router's input bag merges the
33
35
  // body with route params and query, so a flat payload would silently
@@ -40,20 +42,20 @@ export default new Action({
40
42
  const body = (raw && typeof raw === 'object' && !Array.isArray(raw)) ? raw as Record<string, unknown> : {}
41
43
  const prepared = prepareModelFields(resolved.Model, body, true)
42
44
  if (Object.keys(prepared.errors).length > 0)
43
- return { ok: false, error: 'Validation failed.', errors: prepared.errors }
45
+ return response.json({ message: 'Validation failed.', errors: prepared.errors }, 422)
44
46
  const changes = toSnakeCaseKeys(prepared.data)
45
47
  if (Object.keys(changes).length === 0)
46
- return { ok: false, error: 'No writable fields in the request body.' }
48
+ return response.json({ message: 'No writable fields in the request body.' }, 422)
47
49
 
48
50
  try {
49
51
  const row = await resolved.Model.find(id)
50
52
  if (!row)
51
- return { ok: false, error: `${resolved.modelName} ${id} not found.` }
53
+ return response.json({ message: `${resolved.modelName} ${id} not found.` }, 404)
52
54
  await resolved.Model.update(id, changes)
53
55
  return { ok: true, id, changed: Object.keys(changes) }
54
56
  }
55
- catch (e) {
56
- return { ok: false, error: e instanceof Error ? e.message : String(e) }
57
+ catch (error) {
58
+ return dashboardOperationalError(error, 'The model record could not be updated.', 'ModelUpdateAction', 500)
57
59
  }
58
60
  },
59
61
  })