@stacksjs/defaults 0.70.354 → 0.70.356

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 (214) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +17 -2
  2. package/ai/skills/stacks-browse/scripts/browse.ts +207 -6
  3. package/ai/skills/stacks-email/SKILL.md +73 -16
  4. package/app/Actions/ContactAction.ts +2 -2
  5. package/app/Actions/Dashboard/Analytics/EventAnalyticsAction.ts +2 -3
  6. package/app/Actions/Dashboard/Analytics/MarketingAnalyticsAction.ts +2 -3
  7. package/app/Actions/Dashboard/Analytics/SalesAnalyticsAction.ts +2 -3
  8. package/app/Actions/Dashboard/Analytics/WebAnalyticsAction.ts +9 -3
  9. package/app/Actions/Dashboard/Auth/MeAction.ts +12 -14
  10. package/app/Actions/Dashboard/Buddy/BuddyChatAction.ts +2 -3
  11. package/app/Actions/Dashboard/Ci/RepoRunJobsAction.ts +10 -15
  12. package/app/Actions/Dashboard/Ci/RepoRunsAction.ts +12 -14
  13. package/app/Actions/Dashboard/Ci/RunnerHistoryAction.ts +11 -10
  14. package/app/Actions/Dashboard/Ci/StatusAction.ts +2 -11
  15. package/app/Actions/Dashboard/Cloud/CloudIndexAction.ts +8 -1
  16. package/app/Actions/Dashboard/Cloud/ServerlessIndexAction.ts +15 -8
  17. package/app/Actions/Dashboard/Commerce/CommerceCategoriesAction.ts +2 -4
  18. package/app/Actions/Dashboard/Commerce/CommerceCouponsAction.ts +2 -4
  19. package/app/Actions/Dashboard/Commerce/CommerceCustomersAction.ts +2 -4
  20. package/app/Actions/Dashboard/Commerce/CommerceDashboardAction.ts +2 -4
  21. package/app/Actions/Dashboard/Commerce/CommerceDeliveryAction.ts +2 -4
  22. package/app/Actions/Dashboard/Commerce/CommerceGiftCardsAction.ts +2 -4
  23. package/app/Actions/Dashboard/Commerce/CommerceOrdersAction.ts +2 -4
  24. package/app/Actions/Dashboard/Commerce/CommercePaymentsAction.ts +2 -4
  25. package/app/Actions/Dashboard/Commerce/CommercePosAction.ts +2 -4
  26. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +76 -61
  27. package/app/Actions/Dashboard/Commerce/CommercePrintDevicesAction.ts +2 -4
  28. package/app/Actions/Dashboard/Commerce/CommercePrintLogsAction.ts +4 -7
  29. package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +4 -4
  30. package/app/Actions/Dashboard/Commerce/CommerceProductsAction.ts +2 -4
  31. package/app/Actions/Dashboard/Commerce/CommerceTaxesAction.ts +2 -4
  32. package/app/Actions/Dashboard/Commerce/DeliveryRouteIndexAction.ts +2 -4
  33. package/app/Actions/Dashboard/Commerce/DigitalDeliveryIndexAction.ts +2 -4
  34. package/app/Actions/Dashboard/Commerce/DriverIndexAction.ts +2 -4
  35. package/app/Actions/Dashboard/Commerce/LicenseKeyIndexAction.ts +2 -4
  36. package/app/Actions/Dashboard/Commerce/LicenseKeyOptionsAction.ts +2 -4
  37. package/app/Actions/Dashboard/Commerce/ManufacturerIndexAction.ts +2 -4
  38. package/app/Actions/Dashboard/Commerce/PaymentRefundAction.ts +1 -0
  39. package/app/Actions/Dashboard/Commerce/ProductUnitDefaultAction.ts +1 -0
  40. package/app/Actions/Dashboard/Commerce/ProductUnitIndexAction.ts +2 -4
  41. package/app/Actions/Dashboard/Commerce/ProductVariantIndexAction.ts +2 -4
  42. package/app/Actions/Dashboard/Commerce/ProductWaitlistIndexAction.ts +2 -4
  43. package/app/Actions/Dashboard/Commerce/RestaurantWaitlistIndexAction.ts +2 -4
  44. package/app/Actions/Dashboard/Commerce/ReviewIndexAction.ts +2 -4
  45. package/app/Actions/Dashboard/Commerce/ShippingMethodIndexAction.ts +2 -4
  46. package/app/Actions/Dashboard/Commerce/ShippingRateIndexAction.ts +2 -4
  47. package/app/Actions/Dashboard/Commerce/ShippingZoneIndexAction.ts +2 -4
  48. package/app/Actions/Dashboard/Commerce/TaxRateDefaultAction.ts +1 -0
  49. package/app/Actions/Dashboard/DashboardActivityAction.ts +2 -5
  50. package/app/Actions/Dashboard/Data/ActivityIndexAction.ts +2 -5
  51. package/app/Actions/Dashboard/Data/SubscriberIndexAction.ts +2 -5
  52. package/app/Actions/Dashboard/Data/TeamIndexAction.ts +2 -5
  53. package/app/Actions/Dashboard/Data/UserIndexAction.ts +2 -5
  54. package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +26 -21
  55. package/app/Actions/Dashboard/Deployments/GetAverageDeploymentTime.ts +8 -2
  56. package/app/Actions/Dashboard/Deployments/GetDeployScript.ts +3 -6
  57. package/app/Actions/Dashboard/Deployments/GetDeployment.ts +5 -5
  58. package/app/Actions/Dashboard/Deployments/GetDeploymentCount.ts +7 -1
  59. package/app/Actions/Dashboard/Deployments/GetDeploymentLiveTerminalOutput.ts +10 -9
  60. package/app/Actions/Dashboard/Deployments/GetDeployments.ts +2 -4
  61. package/app/Actions/Dashboard/Deployments/GetRecentDeployments.ts +8 -2
  62. package/app/Actions/Dashboard/Deployments/UpdateDeployScript.ts +21 -7
  63. package/app/Actions/Dashboard/Email/CapturedMailIndexAction.ts +2 -4
  64. package/app/Actions/Dashboard/Email/CapturedMailShowAction.ts +3 -4
  65. package/app/Actions/Dashboard/Email/InboxActivityAction.ts +28 -16
  66. package/app/Actions/Dashboard/Email/InboxAttachmentDownloadAction.ts +45 -0
  67. package/app/Actions/Dashboard/Email/InboxDeleteAction.ts +11 -6
  68. package/app/Actions/Dashboard/Email/InboxIndexAction.ts +5 -7
  69. package/app/Actions/Dashboard/Email/InboxMarkReadAction.ts +11 -6
  70. package/app/Actions/Dashboard/Email/InboxMarkUnreadAction.ts +11 -6
  71. package/app/Actions/Dashboard/Email/InboxPreferenceShowAction.ts +10 -4
  72. package/app/Actions/Dashboard/Email/InboxPreferenceUpdateAction.ts +8 -9
  73. package/app/Actions/Dashboard/Email/InboxShowAction.ts +7 -7
  74. package/app/Actions/Dashboard/Email/InboxStatsAction.ts +5 -7
  75. package/app/Actions/Dashboard/Email/inbox-request.ts +33 -0
  76. package/app/Actions/Dashboard/Infrastructure/InsightsAction.ts +15 -14
  77. package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +4 -11
  78. package/app/Actions/Dashboard/Infrastructure/ServerShowAction.ts +3 -6
  79. package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +9 -18
  80. package/app/Actions/Dashboard/Jobs/JobRetryAction.ts +4 -3
  81. package/app/Actions/Dashboard/Jobs/JobShowAction.ts +16 -11
  82. package/app/Actions/Dashboard/Jobs/JobStatsAction.ts +2 -4
  83. package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +7 -8
  84. package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +11 -19
  85. package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +6 -6
  86. package/app/Actions/Dashboard/Kanban/BoardUpdateAction.ts +7 -8
  87. package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +3 -5
  88. package/app/Actions/Dashboard/Kanban/BoardsReorderAction.ts +6 -6
  89. package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +13 -12
  90. package/app/Actions/Dashboard/Kanban/CardCommentDestroyAction.ts +12 -15
  91. package/app/Actions/Dashboard/Kanban/CardCommentStoreAction.ts +21 -46
  92. package/app/Actions/Dashboard/Kanban/CardCommentUpdateAction.ts +43 -0
  93. package/app/Actions/Dashboard/Kanban/CardDestroyAction.ts +7 -8
  94. package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +8 -9
  95. package/app/Actions/Dashboard/Kanban/CardShowAction.ts +6 -7
  96. package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +9 -9
  97. package/app/Actions/Dashboard/Kanban/CardUpdateAction.ts +7 -8
  98. package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +6 -6
  99. package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +7 -8
  100. package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +6 -6
  101. package/app/Actions/Dashboard/Kanban/ColumnUpdateAction.ts +7 -8
  102. package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +6 -6
  103. package/app/Actions/Dashboard/Kanban/LabelDestroyAction.ts +6 -7
  104. package/app/Actions/Dashboard/Kanban/LabelStoreAction.ts +6 -6
  105. package/app/Actions/Dashboard/Kanban/LabelUpdateAction.ts +7 -8
  106. package/app/Actions/Dashboard/Kanban/UsersListAction.ts +3 -4
  107. package/app/Actions/Dashboard/Kanban/kanban-comment.ts +14 -0
  108. package/app/Actions/Dashboard/Kanban/kanban-model.test.ts +36 -0
  109. package/app/Actions/Dashboard/Kanban/kanban-model.ts +14 -7
  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 +11 -8
  122. package/app/Actions/Dashboard/Models/ModelShowAction.ts +97 -192
  123. package/app/Actions/Dashboard/Models/ModelStoreAction.ts +14 -12
  124. package/app/Actions/Dashboard/Models/ModelUpdateAction.ts +18 -16
  125. package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +50 -19
  126. package/app/Actions/Dashboard/Models/model-api.ts +27 -0
  127. package/app/Actions/Dashboard/Models/model-write.ts +22 -15
  128. package/app/Actions/Dashboard/Notifications/NotificationDeliveryHistoryAction.ts +49 -40
  129. package/app/Actions/Dashboard/Notifications/NotificationDeliveryIndexAction.ts +20 -10
  130. package/app/Actions/Dashboard/Notifications/NotificationDeliveryOverviewAction.ts +96 -84
  131. package/app/Actions/Dashboard/Notifications/NotificationDeliveryRetryAction.ts +34 -21
  132. package/app/Actions/Dashboard/Queries/QueryDashboardAction.ts +2 -4
  133. package/app/Actions/Dashboard/Queries/QueryShowAction.ts +3 -3
  134. package/app/Actions/Dashboard/Queue/QueueStatsAction.ts +2 -4
  135. package/app/Actions/Dashboard/Queue/QueueWorkersAction.ts +2 -4
  136. package/app/Actions/Dashboard/Rbac/PermissionDestroyAction.ts +6 -6
  137. package/app/Actions/Dashboard/Rbac/PermissionStoreAction.ts +5 -4
  138. package/app/Actions/Dashboard/Rbac/PermissionsIndexAction.ts +2 -3
  139. package/app/Actions/Dashboard/Rbac/RoleDestroyAction.ts +6 -6
  140. package/app/Actions/Dashboard/Rbac/RolePermissionsShowAction.ts +6 -6
  141. package/app/Actions/Dashboard/Rbac/RolePermissionsSyncAction.ts +6 -9
  142. package/app/Actions/Dashboard/Rbac/RoleStoreAction.ts +5 -4
  143. package/app/Actions/Dashboard/Rbac/RolesIndexAction.ts +4 -6
  144. package/app/Actions/Dashboard/Rbac/UserRolesShowAction.ts +6 -7
  145. package/app/Actions/Dashboard/Rbac/UserRolesSyncAction.ts +6 -13
  146. package/app/Actions/Dashboard/Rbac/UsersListAction.ts +2 -3
  147. package/app/Actions/Dashboard/Rbac/rbac-response.ts +10 -0
  148. package/app/Actions/Dashboard/Realtime/RealtimeStatsAction.ts +2 -4
  149. package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +69 -55
  150. package/app/Actions/Dashboard/Teams/TeamInvitationDestroyAction.ts +34 -16
  151. package/app/Actions/Dashboard/Teams/TeamInvitationResendAction.ts +52 -29
  152. package/app/Actions/Dashboard/Teams/TeamInviteAction.ts +104 -73
  153. package/app/Actions/Dashboard/Teams/TeamMemberDestroyAction.ts +36 -17
  154. package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +41 -28
  155. package/app/Actions/Dashboard/Teams/TeamPeopleIndexAction.ts +71 -58
  156. package/app/Actions/Dashboard/Teams/team-invitation-delivery.ts +29 -7
  157. package/app/Actions/Dashboard/Teams/team-member-count.ts +4 -2
  158. package/app/Actions/Dashboard/Teams/team-records.test.ts +2 -0
  159. package/app/Actions/Dashboard/Teams/team-records.ts +2 -0
  160. package/app/Actions/Dashboard/dashboard-request.ts +13 -0
  161. package/app/Actions/Dashboard/dashboard-response.ts +16 -0
  162. package/app/Actions/SendWelcomeEmail.ts +1 -1
  163. package/app/Actions/Teams/AcceptInvitationAction.ts +74 -51
  164. package/app/Actions/Teams/ShowInvitationAction.ts +21 -14
  165. package/app/Actions/Teams/team-response.ts +23 -0
  166. package/app/Mail/OrderConfirmation.ts +1 -1
  167. package/app/Mail/SubscriptionConfirmation.ts +1 -1
  168. package/app/Mail/TeamInvitation.ts +1 -1
  169. package/app/Models/CardComment.ts +3 -4
  170. package/app/Models/EmailIdempotency.ts +64 -0
  171. package/app/Models/EmailSuppression.ts +52 -0
  172. package/app/Models/EmailWebhookEvent.ts +51 -0
  173. package/app/Models/TeamInvitation.ts +13 -0
  174. package/functions/dashboard-api.ts +51 -10
  175. package/functions/data.ts +0 -1
  176. package/functions/inbox.test.ts +2 -0
  177. package/functions/inbox.ts +27 -2
  178. package/functions/jobs.ts +0 -1
  179. package/functions/public-application-url.test.ts +13 -0
  180. package/functions/public-application-url.ts +27 -0
  181. package/ide/vscode/package.json +1 -1
  182. package/package.json +1 -1
  183. package/resources/components/Dashboard/Auth/AccessTokens.stx +15 -0
  184. package/resources/components/Dashboard/Ci/CiDashboard.stx +24 -6
  185. package/resources/components/Dashboard/Commerce/CommerceTaxDialog.stx +1 -1
  186. package/resources/components/Dashboard/Commerce/ProductWaitlistDashboard.stx +8 -7
  187. package/resources/components/Dashboard/Commerce/RestaurantWaitlistDashboard.stx +8 -7
  188. package/resources/components/Dashboard/Content/BlogDashboard.stx +14 -2
  189. package/resources/components/Dashboard/Content/PostsDashboard.stx +7 -1
  190. package/resources/components/Dashboard/Data/DataRecords.stx +0 -4
  191. package/resources/components/Dashboard/Deployments/DeploymentDetail.stx +16 -2
  192. package/resources/components/Dashboard/Email/EmailNavigation.stx +5 -5
  193. package/resources/components/Dashboard/Email/EmailSidebar.stx +1 -1
  194. package/resources/components/Dashboard/Email/InboxAttachmentList.stx +53 -0
  195. package/resources/components/Dashboard/Email/InboxDashboard.stx +5 -4
  196. package/resources/components/Dashboard/Email/InboxMessageDetail.stx +6 -4
  197. package/resources/components/Dashboard/Jobs/JobDetail.stx +3 -2
  198. package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +63 -6
  199. package/resources/components/Dashboard/Kanban/KanbanBoardsDashboard.stx +3 -6
  200. package/resources/components/Dashboard/Management/PermissionsDashboard.stx +4 -4
  201. package/resources/components/Dashboard/Models/ModelRecordsDashboard.stx +7 -13
  202. package/resources/components/Dashboard/Models/ModelsOverview.stx +1 -1
  203. package/resources/components/Dashboard/NotFoundDashboard.stx +2 -5
  204. package/resources/components/Dashboard/Queries/QueryDashboard.stx +6 -2
  205. package/resources/components/Dashboard/Settings/AppearanceSettingsDashboard.stx +4 -4
  206. package/resources/components/Dashboard/UI/EmptyState.stx +2 -2
  207. package/resources/components/Dashboard/UI/PageLayout.stx +3 -3
  208. package/resources/functions/dashboard/sidebar.ts +4 -4
  209. package/routes/dashboard-api.ts +8 -20
  210. package/views/dashboard/.discovered-models.json +54 -0
  211. package/views/dashboard/layouts/default.stx +19 -16
  212. package/views/dashboard/stores/auth.ts +15 -14
  213. package/views/dashboard/stores/ci.ts +13 -7
  214. package/views/dashboard/stores/kanban.ts +48 -11
@@ -1,7 +1,8 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
- import { env } from '@stacksjs/env'
3
- import { request, response as routerResponse } from '@stacksjs/router'
3
+ import { response as routerResponse } from '@stacksjs/router'
4
4
  import { loadModelIfExists, safeGet } from '../../../../resources/functions/dashboard/data'
5
+ import { dashboardOperationalError } from '../dashboard-response'
5
6
  import { isValidModelSlug, type ModelCreateField, type ModelWriteCapabilities, modelCreateFields, modelSchemaColumns, modelWriteCapabilities, slugToPascal } from './model-write'
6
7
 
7
8
  /**
@@ -22,8 +23,8 @@ import { isValidModelSlug, type ModelCreateField, type ModelWriteCapabilities, m
22
23
  * queries, so scopes, casts and accessors all apply.
23
24
  * 2. `loadModelIfExists(Name)` — path-map lookup, covers models the ORM has not
24
25
  * registered as a global.
25
- * 3. Raw SQLite last resort for a table with no model file at all
26
- * (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.
27
28
  */
28
29
 
29
30
  interface ColumnMeta {
@@ -49,8 +50,6 @@ interface ResponseShape {
49
50
  dir: 'asc' | 'desc'
50
51
  q: string
51
52
  filters: Record<string, string>
52
- source: 'orm' | 'sqlite-fallback'
53
- /** False for tables reached through the SQLite fallback: no model, no writes. */
54
53
  writable: boolean
55
54
  writeCapabilities: ModelWriteCapabilities
56
55
  createFields: ModelCreateField[]
@@ -84,6 +83,15 @@ function humanize(column: string): string {
84
83
  // filter by it.
85
84
  const HIDDEN_COLUMNS = new Set(['password', 'remember_token', 'api_token', 'access_token', 'refresh_token', 'secret', 'two_factor_secret'])
86
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
+
87
95
  /**
88
96
  * Column identifiers reach the query builder as raw, unquoted SQL, so only
89
97
  * valid identifiers from the table's own column list may ever be used.
@@ -108,14 +116,11 @@ function typeForColumn(column: string, values: unknown[]): ColumnMeta['type'] {
108
116
  return inferType(values)
109
117
  }
110
118
 
111
- function queryParams(): URLSearchParams {
112
- const query = ((request as any).query || {}) as Record<string, string | string[] | undefined>
113
- const params = new URLSearchParams()
114
- for (const [key, value] of Object.entries(query)) {
115
- if (value === undefined) continue
116
- params.set(key, Array.isArray(value) ? String(value[0]) : String(value))
117
- }
118
- return params
119
+ function queryValue(request: RequestInstance, key: string, fallback = ''): string {
120
+ const value = request.get<unknown>(key, fallback)
121
+ if (Array.isArray(value))
122
+ return value.length > 0 ? String(value[0]) : fallback
123
+ return value == null ? fallback : String(value)
119
124
  }
120
125
 
121
126
  /** `filters` arrives as a JSON object so column names stay unambiguous. */
@@ -153,15 +158,6 @@ export function parseFilters(raw: string | null): Record<string, string> {
153
158
  return out
154
159
  }
155
160
 
156
- /**
157
- * Signals that a free-text search spans several columns, which the query
158
- * builder cannot express (`whereLike` is single-column and `orWhere` only
159
- * takes equality pairs). The SQLite path builds that OR with bound
160
- * parameters instead. Distinct from a real failure so it never surfaces
161
- * to the page as an error.
162
- */
163
- class SearchUnsupported extends Error {}
164
-
165
161
  function resolveOrmModel(modelName: string): any {
166
162
  const injected = (globalThis as Record<string, any>)[modelName]
167
163
  // The ORM injects Proxies that answer `undefined` for every property
@@ -176,23 +172,22 @@ export default new Action({
176
172
  description: 'Queries a single model by URL slug with paging, sorting, search and column filters.',
177
173
  method: 'GET',
178
174
  apiResponse: true,
179
- async handle(req: { getParam?: (name: string) => unknown, route?: { params?: { slug?: string } } }) {
180
- const slug = String(req?.getParam?.('slug') ?? req?.route?.params?.slug ?? '')
175
+ async handle(request: RequestInstance) {
176
+ const slug = request.getParam('slug')
181
177
  if (!isValidModelSlug(slug))
182
178
  return routerResponse.json({ message: 'Model slug must be lowercase kebab-case.' }, 400)
183
179
 
184
180
  const modelName = slugToPascal(slug)
185
181
  const tableName = pluralize(pascalToSnake(modelName))
186
182
 
187
- const params = queryParams()
188
- const page = Math.max(1, Number.parseInt(params.get('page') || '1', 10) || 1)
189
- const perPage = Math.min(MAX_PER_PAGE, Math.max(1, Number.parseInt(params.get('per_page') || String(DEFAULT_PER_PAGE), 10) || DEFAULT_PER_PAGE))
190
- const requestedSort = (params.get('sort') || '').trim()
191
- const dir: 'asc' | 'desc' = params.get('dir') === 'asc' ? 'asc' : 'desc'
192
- const q = (params.get('q') || '').trim()
183
+ const page = Math.max(1, Number.parseInt(queryValue(request, 'page', '1'), 10) || 1)
184
+ const perPage = Math.min(MAX_PER_PAGE, Math.max(1, Number.parseInt(queryValue(request, 'per_page', String(DEFAULT_PER_PAGE)), 10) || DEFAULT_PER_PAGE))
185
+ const requestedSort = queryValue(request, 'sort').trim()
186
+ const dir: 'asc' | 'desc' = queryValue(request, 'dir') === 'asc' ? 'asc' : 'desc'
187
+ const q = queryValue(request, 'q').trim()
193
188
  let filters: Record<string, string>
194
189
  try {
195
- filters = parseFilters(params.get('filters'))
190
+ filters = parseFilters(queryValue(request, 'filters') || null)
196
191
  }
197
192
  catch (error) {
198
193
  return routerResponse.json({
@@ -216,182 +211,92 @@ export default new Action({
216
211
  dir,
217
212
  q,
218
213
  filters,
219
- source: 'orm',
220
214
  writable: false,
221
215
  writeCapabilities: { create: false, update: false, destroy: false },
222
216
  createFields: [],
223
217
  error: null,
224
218
  }
225
219
 
226
- const Model = resolveOrmModel(modelName) ?? await loadModelIfExists(modelName)
227
- const hasOrm = Boolean(Model) && typeof Model.where === 'function'
228
-
229
- if (hasOrm) {
230
- try {
231
- response.tableName = String(Model.table || tableName)
232
- // The column list has to come from a real row: the ORM does not
233
- // expose a database schema reflection API. Empty tables fall back
234
- // to the model definition, which is also the migration source.
235
- const probe = await Model.orderByDesc('id').take(1).get() as Array<Record<string, unknown>>
236
- const first = Array.isArray(probe) ? probe[0] : undefined
237
- const shape = first && typeof first === 'object'
238
- ? ((first as { attributes?: Record<string, unknown> }).attributes ?? first)
239
- : {}
240
- response.columns = Object.keys(shape).filter(k => typeof k === 'string' && !k.startsWith('_'))
241
- if (response.columns.length === 0)
242
- response.columns = modelSchemaColumns(Model)
243
-
244
- const allowed = new Set(response.columns.filter(c => !HIDDEN_COLUMNS.has(c)))
245
- const unknownFilter = Object.keys(filters).find(column => !isSafeColumn(column, allowed))
246
- if (unknownFilter)
247
- return routerResponse.json({ message: `Unknown model filter column "${unknownFilter}".` }, 422)
248
- if (requestedSort && !isSafeColumn(requestedSort, allowed))
249
- return routerResponse.json({ message: `Unknown model sort column "${requestedSort}".` }, 422)
250
-
251
- const sort = isSafeColumn(requestedSort, allowed)
252
- ? requestedSort
253
- : (allowed.has('id') ? 'id' : (response.columns[0] ?? ''))
254
- response.sort = sort
255
-
256
- // Text columns are decided from the probe row, before any query is
257
- // built, so the search clause does not depend on response metadata
258
- // that is only assembled after the rows come back.
259
- const searchColumns = [...allowed].filter(c => typeof safeGet(first, c, null) === 'string')
260
-
261
- const build = () => {
262
- let chain = Model as any
263
- for (const [column, value] of Object.entries(filters)) {
264
- if (!isSafeColumn(column, allowed)) continue
265
- chain = chain.where(column, value)
266
- }
267
- // Global search is a LIKE across every text column. The builder's
268
- // `whereLike` covers one column and `orWhere` only takes equality
269
- // pairs, so a multi-column OR-LIKE has no ORM expression; that
270
- // case is served by the SQLite path below instead, which builds
271
- // the OR with bound parameters. Writes stay enabled either way,
272
- // because the model still exists.
273
- if (q && searchColumns.length === 1)
274
- chain = chain.whereLike(searchColumns[0], `%${q}%`)
275
- return chain
276
- }
277
-
278
- // A multi-column search has no ORM expression, so hand that one
279
- // query to the SQLite path — without recording an error, because it
280
- // is a gap in the builder's vocabulary, not a failure.
281
- if (q && searchColumns.length > 1)
282
- throw new SearchUnsupported()
283
-
284
- response.totalCount = q && searchColumns.length === 0
285
- ? 0
286
- : await build().count()
287
- const rows = response.totalCount === 0
288
- ? []
289
- : await build()
290
- .orderBy(sort, dir)
291
- .skip((page - 1) * perPage)
292
- .take(perPage)
293
- .get() as Array<Record<string, unknown>>
294
-
295
- // Flatten to plain JSON-safe objects — proxy models and
296
- // accessor-rich rows do not serialise cleanly across the wire.
297
- response.rows = (Array.isArray(rows) ? rows : []).map((row) => {
298
- const flat: Record<string, unknown> = {}
299
- for (const col of response.columns)
300
- flat[col] = safeGet(row, col, null)
301
- return flat
302
- })
303
- }
304
- catch (e) {
305
- if (e instanceof SearchUnsupported) {
306
- response.source = 'sqlite-fallback'
307
- }
308
- else {
309
- return routerResponse.json({
310
- message: `Could not query model ${modelName}: ${e instanceof Error ? e.message : String(e)}`,
311
- }, 503)
312
- }
313
- }
220
+ let Model: any
221
+ try {
222
+ Model = resolveOrmModel(modelName) ?? await loadModelIfExists(modelName)
314
223
  }
315
- else {
316
- response.source = 'sqlite-fallback'
224
+ catch (error) {
225
+ return dashboardOperationalError(error, `${modelName} could not be loaded.`, 'ModelShowAction.resolve')
317
226
  }
227
+ if (!Model || typeof Model.where !== 'function')
228
+ return routerResponse.json({ message: `No ORM model named ${modelName}.` }, 404)
318
229
 
319
- if (response.source === 'sqlite-fallback') {
320
- try {
321
- const { Database } = await import('bun:sqlite')
322
- const db = new Database(env.DB_DATABASE_PATH || 'database/stacks.sqlite', { readonly: true })
323
- try {
324
- if (!/^[A-Za-z_]\w*$/.test(response.tableName))
325
- return routerResponse.json({ message: `Model table "${response.tableName}" is not a safe SQL identifier.` }, 500)
326
-
327
- const tableInfo = db.query(`PRAGMA table_info(${response.tableName})`).all() as Array<{ name: string, type: string }>
328
- if (tableInfo.length === 0)
329
- return routerResponse.json({ message: `Model table "${response.tableName}" does not exist.` }, 404)
330
-
331
- response.columns = tableInfo.map(c => c.name)
332
-
333
- if (response.columns.length > 0) {
334
- const allowed = new Set(response.columns.filter(c => !HIDDEN_COLUMNS.has(c)))
335
- const unknownFilter = Object.keys(filters).find(column => !isSafeColumn(column, allowed))
336
- if (unknownFilter)
337
- return routerResponse.json({ message: `Unknown model filter column "${unknownFilter}".` }, 422)
338
- if (requestedSort && !isSafeColumn(requestedSort, allowed))
339
- return routerResponse.json({ message: `Unknown model sort column "${requestedSort}".` }, 422)
340
-
341
- const sort = isSafeColumn(requestedSort, allowed)
342
- ? requestedSort
343
- : (allowed.has('id') ? 'id' : response.columns[0])
344
- response.sort = sort
345
-
346
- // Every value is bound; only identifiers are interpolated, and
347
- // those come from the allowlist built out of PRAGMA above.
348
- const wheres: string[] = []
349
- const values: unknown[] = []
350
- for (const [column, value] of Object.entries(filters)) {
351
- if (!isSafeColumn(column, allowed)) continue
352
- wheres.push(`${column} = ?`)
353
- values.push(value)
354
- }
355
- if (q) {
356
- const textColumns = tableInfo
357
- .filter(c => allowed.has(c.name) && /char|text|clob/i.test(c.type || ''))
358
- .map(c => c.name)
359
- if (textColumns.length > 0) {
360
- wheres.push(`(${textColumns.map(c => `${c} LIKE ?`).join(' OR ')})`)
361
- for (const _ of textColumns) values.push(`%${q}%`)
362
- }
363
- }
364
- const whereSql = wheres.length > 0 ? ` WHERE ${wheres.join(' AND ')}` : ''
365
-
366
- const countRow = db.query(`SELECT COUNT(*) as count FROM ${response.tableName}${whereSql}`).get(...values as any[]) as { count?: number } | null
367
- response.totalCount = countRow?.count ?? 0
368
- response.rows = db
369
- .query(`SELECT * FROM ${response.tableName}${whereSql} ORDER BY ${sort} ${dir.toUpperCase()} LIMIT ? OFFSET ?`)
370
- .all(...values as any[], perPage, (page - 1) * perPage) as Array<Record<string, unknown>>
371
- response.error = null
372
- }
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)
373
260
  }
374
- finally {
375
- 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
+ })
376
270
  }
271
+ return chain
377
272
  }
378
- catch (e) {
379
- return routerResponse.json({
380
- message: `Could not query table ${response.tableName}: ${e instanceof Error ? e.message : String(e)}`,
381
- }, 503)
382
- }
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')
383
294
  }
384
295
 
385
- // Writability follows the model's declared useApi routes, not which
386
- // engine answered this particular query. A multi-column search can be
387
- // served by SQLite for a model that still creates and updates through
388
- // the ORM.
389
- response.writeCapabilities = hasOrm
390
- ? modelWriteCapabilities(Model)
391
- : { create: false, update: false, destroy: false }
296
+ response.writeCapabilities = modelWriteCapabilities(Model)
392
297
  response.writable = Object.values(response.writeCapabilities).some(Boolean)
393
298
  response.createFields = response.writeCapabilities.create ? modelCreateFields(Model) : []
394
- response.displayColumns = response.columns.filter(col => !HIDDEN_COLUMNS.has(col) && !col.startsWith('_'))
299
+ response.displayColumns = response.columns.filter(col => !col.startsWith('_'))
395
300
  response.columnMeta = response.displayColumns.map(name => ({
396
301
  name,
397
302
  label: humanize(name),
@@ -1,31 +1,33 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { toSnakeCaseKeys } from '@stacksjs/orm'
3
- import { request } from '@stacksjs/router'
4
+ import { response } from '@stacksjs/router'
5
+ import { dashboardOperationalError } from '../dashboard-response'
4
6
  import { prepareModelFields, resolveWritableModel } from './model-write'
5
7
 
8
+ interface ModelWriteInput {
9
+ fields?: unknown
10
+ }
11
+
6
12
  export default new Action({
7
13
  name: 'Dashboard Model Store',
8
14
  description: 'Creates one row through a model declared useApi store contract.',
9
15
  method: 'POST',
10
16
  apiResponse: true,
11
- async handle(req: {
12
- getParam?: (name: string) => unknown
13
- all?: () => Record<string, unknown>
14
- route?: { params?: { slug?: string } }
15
- }) {
16
- const resolved = await resolveWritableModel(String(req?.getParam?.('slug') ?? req?.route?.params?.slug ?? ''), 'store')
17
+ async handle(request: RequestInstance<ModelWriteInput>) {
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
- const input = (req.all?.() ?? (request as any).all?.() ?? {}) as Record<string, unknown>
22
+ const input = request.all()
21
23
  const raw = input.fields
22
24
  const body = (raw && typeof raw === 'object' && !Array.isArray(raw)) ? raw as Record<string, unknown> : {}
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,8 +1,14 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { toSnakeCaseKeys } from '@stacksjs/orm'
3
- import { request } from '@stacksjs/router'
4
+ import { response } from '@stacksjs/router'
5
+ import { dashboardOperationalError } from '../dashboard-response'
4
6
  import { parseRowId, prepareModelFields, resolveWritableModel } from './model-write'
5
7
 
8
+ interface ModelWriteInput {
9
+ fields?: unknown
10
+ }
11
+
6
12
  /**
7
13
  * `PATCH /api/dashboard/models/{slug}/{id}`.
8
14
  *
@@ -16,18 +22,14 @@ export default new Action({
16
22
  description: 'Updates one row of a model from the dashboard model browser.',
17
23
  method: 'PATCH',
18
24
  apiResponse: true,
19
- async handle(req: {
20
- getParam?: (name: string) => unknown
21
- all?: () => Record<string, unknown>
22
- route?: { params?: { slug?: string, id?: string } }
23
- }) {
24
- const resolved = await resolveWritableModel(String(req?.getParam?.('slug') ?? req?.route?.params?.slug ?? ''), 'update')
25
+ async handle(request: RequestInstance<ModelWriteInput>) {
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
- const id = parseRowId(req?.getParam?.('id') ?? req?.route?.params?.id)
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
@@ -35,25 +37,25 @@ export default new Action({
35
37
  // columns — and `slug` is a real column on plenty of models, so it
36
38
  // cannot simply be blocklisted. One level of nesting keeps the two
37
39
  // namespaces apart.
38
- const input = (req.all?.() ?? (request as any).all?.() ?? {}) as Record<string, unknown>
40
+ const input = request.all()
39
41
  const raw = input.fields
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
  })
@@ -3,6 +3,8 @@ import { existsSync, readdirSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import process from 'node:process'
5
5
  import { countRows } from '../../../../resources/functions/dashboard/data'
6
+ import { dashboardOperationalError, dashboardOperationalIssue } from '../dashboard-response'
7
+ import { modelApiConfiguration } from './model-api'
6
8
 
7
9
  /**
8
10
  * `GET /api/dashboard/models` (stacksjs/stacks#1838).
@@ -40,6 +42,12 @@ interface ModelGroup {
40
42
  countLabel: string
41
43
  }
42
44
 
45
+ interface DiscoveredModel {
46
+ name: string
47
+ file: string
48
+ source: 'userland' | 'framework'
49
+ }
50
+
43
51
  function pascalToSnake(s: string): string {
44
52
  return s.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase()
45
53
  }
@@ -91,6 +99,21 @@ function categoryLabel(k: string): string {
91
99
  return k === 'userland' ? 'Your Models' : k.charAt(0).toUpperCase() + k.slice(1)
92
100
  }
93
101
 
102
+ function unavailableModelRow(model: DiscoveredModel, error: string): ModelRow {
103
+ return {
104
+ name: model.name,
105
+ table: pluralize(pascalToSnake(model.name)),
106
+ href: `/models/${pascalToKebab(model.name)}`,
107
+ count: null,
108
+ attributeCount: 0,
109
+ category: categorize(model),
110
+ source: model.source,
111
+ apiUri: '',
112
+ apiRoutes: [],
113
+ error,
114
+ }
115
+ }
116
+
94
117
  export default new Action({
95
118
  name: 'Dashboard Models Index',
96
119
  description: 'List every model + live record count for the dashboard models overview.',
@@ -101,11 +124,18 @@ export default new Action({
101
124
  const userlandDir = join(cwd, 'app', 'Models')
102
125
  const defaultsDir = join(cwd, 'storage/framework/defaults/app/Models')
103
126
 
104
- const userland = walkModels(userlandDir, false)
105
- const defaults = walkModels(defaultsDir, true)
127
+ let userland: Array<Omit<DiscoveredModel, 'source'>>
128
+ let defaults: Array<Omit<DiscoveredModel, 'source'>>
129
+ try {
130
+ userland = walkModels(userlandDir, false)
131
+ defaults = walkModels(defaultsDir, true)
132
+ }
133
+ catch (error) {
134
+ return dashboardOperationalError(error, 'Model catalog could not be loaded.', 'ModelsIndexAction.catalog')
135
+ }
106
136
 
107
137
  const seen = new Set<string>()
108
- const merged: Array<{ name: string, file: string, source: 'userland' | 'framework' }> = []
138
+ const merged: DiscoveredModel[] = []
109
139
  for (const list of [userland, defaults]) {
110
140
  for (const m of list) {
111
141
  if (seen.has(m.name)) continue
@@ -121,28 +151,29 @@ export default new Action({
121
151
  Model = module.default ?? module
122
152
  }
123
153
  catch (error) {
124
- return {
125
- name: m.name,
126
- table: pluralize(pascalToSnake(m.name)),
127
- href: `/models/${pascalToKebab(m.name)}`,
128
- count: null,
129
- attributeCount: 0,
130
- category: categorize(m),
131
- source: m.source,
132
- apiUri: '',
133
- apiRoutes: [],
134
- error: error instanceof Error ? error.message : String(error),
135
- }
154
+ return unavailableModelRow(
155
+ m,
156
+ dashboardOperationalIssue(error, 'Model definition could not be loaded.', `ModelsIndexAction.import.${m.name}`),
157
+ )
136
158
  }
137
159
 
138
- const api = Model.traits?.useApi
160
+ let api: ReturnType<typeof modelApiConfiguration>
161
+ try {
162
+ api = modelApiConfiguration(Model)
163
+ }
164
+ catch (error) {
165
+ return unavailableModelRow(
166
+ m,
167
+ dashboardOperationalIssue(error, 'Model metadata could not be loaded.', `ModelsIndexAction.metadata.${m.name}`),
168
+ )
169
+ }
139
170
  let count: number | null = null
140
171
  let error: string | null = null
141
172
  try {
142
173
  count = await countRows(Model)
143
174
  }
144
175
  catch (cause) {
145
- error = cause instanceof Error ? cause.message : String(cause)
176
+ error = dashboardOperationalIssue(cause, 'Model count could not be loaded.', `ModelsIndexAction.count.${m.name}`)
146
177
  }
147
178
 
148
179
  return {
@@ -153,8 +184,8 @@ export default new Action({
153
184
  attributeCount: Object.keys(Model.attributes || {}).length,
154
185
  category: categorize(m),
155
186
  source: m.source,
156
- apiUri: typeof api === 'object' && typeof api.uri === 'string' ? `/api/${api.uri}` : '',
157
- apiRoutes: typeof api === 'object' && Array.isArray(api.routes) ? api.routes.map(String) : [],
187
+ apiUri: api.uri ? `/api/${api.uri}` : '',
188
+ apiRoutes: api.routes,
158
189
  error,
159
190
  }
160
191
  }))
@@ -0,0 +1,27 @@
1
+ export const DEFAULT_MODEL_API_ROUTES = ['index', 'show', 'store', 'update', 'destroy'] as const
2
+
3
+ export interface ModelApiConfiguration {
4
+ uri: string
5
+ routes: string[]
6
+ }
7
+
8
+ /**
9
+ * Normalize a model's useApi trait exactly as the ORM route generator does.
10
+ * An enabled trait without explicit routes receives the complete REST set,
11
+ * while an explicit empty routes array remains intentionally empty.
12
+ */
13
+ export function modelApiConfiguration(Model: any): ModelApiConfiguration {
14
+ const useApi = Model?.traits?.useApi
15
+ if (!useApi)
16
+ return { uri: '', routes: [] }
17
+
18
+ const config = typeof useApi === 'object' ? useApi : {}
19
+ const fallbackUri = String(Model?.table || `${String(Model?.name || '').toLowerCase()}s`)
20
+
21
+ return {
22
+ uri: String(config.uri || fallbackUri),
23
+ routes: Array.isArray(config.routes)
24
+ ? config.routes.map(String)
25
+ : [...DEFAULT_MODEL_API_ROUTES],
26
+ }
27
+ }