@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.
- package/ai/skills/stacks-browse/SKILL.md +17 -2
- package/ai/skills/stacks-browse/scripts/browse.ts +207 -6
- package/ai/skills/stacks-email/SKILL.md +73 -16
- package/app/Actions/ContactAction.ts +2 -2
- package/app/Actions/Dashboard/Analytics/EventAnalyticsAction.ts +2 -3
- package/app/Actions/Dashboard/Analytics/MarketingAnalyticsAction.ts +2 -3
- package/app/Actions/Dashboard/Analytics/SalesAnalyticsAction.ts +2 -3
- package/app/Actions/Dashboard/Analytics/WebAnalyticsAction.ts +9 -3
- package/app/Actions/Dashboard/Auth/MeAction.ts +12 -14
- package/app/Actions/Dashboard/Buddy/BuddyChatAction.ts +2 -3
- package/app/Actions/Dashboard/Ci/RepoRunJobsAction.ts +10 -15
- package/app/Actions/Dashboard/Ci/RepoRunsAction.ts +12 -14
- package/app/Actions/Dashboard/Ci/RunnerHistoryAction.ts +11 -10
- package/app/Actions/Dashboard/Ci/StatusAction.ts +2 -11
- package/app/Actions/Dashboard/Cloud/CloudIndexAction.ts +8 -1
- package/app/Actions/Dashboard/Cloud/ServerlessIndexAction.ts +15 -8
- package/app/Actions/Dashboard/Commerce/CommerceCategoriesAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceCouponsAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceCustomersAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceDashboardAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceDeliveryAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceGiftCardsAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceOrdersAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommercePaymentsAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommercePosAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +76 -61
- package/app/Actions/Dashboard/Commerce/CommercePrintDevicesAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommercePrintLogsAction.ts +4 -7
- package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +4 -4
- package/app/Actions/Dashboard/Commerce/CommerceProductsAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceTaxesAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/DeliveryRouteIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/DigitalDeliveryIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/DriverIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/LicenseKeyIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/LicenseKeyOptionsAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ManufacturerIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/PaymentRefundAction.ts +1 -0
- package/app/Actions/Dashboard/Commerce/ProductUnitDefaultAction.ts +1 -0
- package/app/Actions/Dashboard/Commerce/ProductUnitIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ProductVariantIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ProductWaitlistIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/RestaurantWaitlistIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ReviewIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ShippingMethodIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ShippingRateIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ShippingZoneIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/TaxRateDefaultAction.ts +1 -0
- package/app/Actions/Dashboard/DashboardActivityAction.ts +2 -5
- package/app/Actions/Dashboard/Data/ActivityIndexAction.ts +2 -5
- package/app/Actions/Dashboard/Data/SubscriberIndexAction.ts +2 -5
- package/app/Actions/Dashboard/Data/TeamIndexAction.ts +2 -5
- package/app/Actions/Dashboard/Data/UserIndexAction.ts +2 -5
- package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +26 -21
- package/app/Actions/Dashboard/Deployments/GetAverageDeploymentTime.ts +8 -2
- package/app/Actions/Dashboard/Deployments/GetDeployScript.ts +3 -6
- package/app/Actions/Dashboard/Deployments/GetDeployment.ts +5 -5
- package/app/Actions/Dashboard/Deployments/GetDeploymentCount.ts +7 -1
- package/app/Actions/Dashboard/Deployments/GetDeploymentLiveTerminalOutput.ts +10 -9
- package/app/Actions/Dashboard/Deployments/GetDeployments.ts +2 -4
- package/app/Actions/Dashboard/Deployments/GetRecentDeployments.ts +8 -2
- package/app/Actions/Dashboard/Deployments/UpdateDeployScript.ts +21 -7
- package/app/Actions/Dashboard/Email/CapturedMailIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Email/CapturedMailShowAction.ts +3 -4
- package/app/Actions/Dashboard/Email/InboxActivityAction.ts +28 -16
- package/app/Actions/Dashboard/Email/InboxAttachmentDownloadAction.ts +45 -0
- package/app/Actions/Dashboard/Email/InboxDeleteAction.ts +11 -6
- package/app/Actions/Dashboard/Email/InboxIndexAction.ts +5 -7
- package/app/Actions/Dashboard/Email/InboxMarkReadAction.ts +11 -6
- package/app/Actions/Dashboard/Email/InboxMarkUnreadAction.ts +11 -6
- package/app/Actions/Dashboard/Email/InboxPreferenceShowAction.ts +10 -4
- package/app/Actions/Dashboard/Email/InboxPreferenceUpdateAction.ts +8 -9
- package/app/Actions/Dashboard/Email/InboxShowAction.ts +7 -7
- package/app/Actions/Dashboard/Email/InboxStatsAction.ts +5 -7
- package/app/Actions/Dashboard/Email/inbox-request.ts +33 -0
- package/app/Actions/Dashboard/Infrastructure/InsightsAction.ts +15 -14
- package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +4 -11
- package/app/Actions/Dashboard/Infrastructure/ServerShowAction.ts +3 -6
- package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +9 -18
- package/app/Actions/Dashboard/Jobs/JobRetryAction.ts +4 -3
- package/app/Actions/Dashboard/Jobs/JobShowAction.ts +16 -11
- package/app/Actions/Dashboard/Jobs/JobStatsAction.ts +2 -4
- package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +7 -8
- package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +11 -19
- package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +6 -6
- package/app/Actions/Dashboard/Kanban/BoardUpdateAction.ts +7 -8
- package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +3 -5
- package/app/Actions/Dashboard/Kanban/BoardsReorderAction.ts +6 -6
- package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +13 -12
- package/app/Actions/Dashboard/Kanban/CardCommentDestroyAction.ts +12 -15
- package/app/Actions/Dashboard/Kanban/CardCommentStoreAction.ts +21 -46
- package/app/Actions/Dashboard/Kanban/CardCommentUpdateAction.ts +43 -0
- package/app/Actions/Dashboard/Kanban/CardDestroyAction.ts +7 -8
- package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +8 -9
- package/app/Actions/Dashboard/Kanban/CardShowAction.ts +6 -7
- package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +9 -9
- package/app/Actions/Dashboard/Kanban/CardUpdateAction.ts +7 -8
- package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +6 -6
- package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +7 -8
- package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +6 -6
- package/app/Actions/Dashboard/Kanban/ColumnUpdateAction.ts +7 -8
- package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +6 -6
- package/app/Actions/Dashboard/Kanban/LabelDestroyAction.ts +6 -7
- package/app/Actions/Dashboard/Kanban/LabelStoreAction.ts +6 -6
- package/app/Actions/Dashboard/Kanban/LabelUpdateAction.ts +7 -8
- package/app/Actions/Dashboard/Kanban/UsersListAction.ts +3 -4
- package/app/Actions/Dashboard/Kanban/kanban-comment.ts +14 -0
- package/app/Actions/Dashboard/Kanban/kanban-model.test.ts +36 -0
- package/app/Actions/Dashboard/Kanban/kanban-model.ts +14 -7
- package/app/Actions/Dashboard/Kanban/kanban-response.ts +5 -0
- package/app/Actions/Dashboard/Library/CreateComponent.ts +15 -6
- package/app/Actions/Dashboard/Library/CreateFunction.ts +15 -6
- package/app/Actions/Dashboard/Library/DependencyIndexAction.ts +88 -77
- package/app/Actions/Dashboard/Library/GetAverageReleaseTime.ts +21 -15
- package/app/Actions/Dashboard/Library/GetComponents.ts +14 -7
- package/app/Actions/Dashboard/Library/GetDownloadCount.ts +14 -5
- package/app/Actions/Dashboard/Library/GetFunctions.ts +12 -5
- package/app/Actions/Dashboard/Library/GetReleaseCount.ts +14 -5
- package/app/Actions/Dashboard/Library/GetReleases.ts +17 -10
- package/app/Actions/Dashboard/Library/PackageIndexAction.ts +14 -7
- package/app/Actions/Dashboard/Models/ModelDestroyAction.ts +11 -8
- package/app/Actions/Dashboard/Models/ModelShowAction.ts +97 -192
- package/app/Actions/Dashboard/Models/ModelStoreAction.ts +14 -12
- package/app/Actions/Dashboard/Models/ModelUpdateAction.ts +18 -16
- package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +50 -19
- package/app/Actions/Dashboard/Models/model-api.ts +27 -0
- package/app/Actions/Dashboard/Models/model-write.ts +22 -15
- package/app/Actions/Dashboard/Notifications/NotificationDeliveryHistoryAction.ts +49 -40
- package/app/Actions/Dashboard/Notifications/NotificationDeliveryIndexAction.ts +20 -10
- package/app/Actions/Dashboard/Notifications/NotificationDeliveryOverviewAction.ts +96 -84
- package/app/Actions/Dashboard/Notifications/NotificationDeliveryRetryAction.ts +34 -21
- package/app/Actions/Dashboard/Queries/QueryDashboardAction.ts +2 -4
- package/app/Actions/Dashboard/Queries/QueryShowAction.ts +3 -3
- package/app/Actions/Dashboard/Queue/QueueStatsAction.ts +2 -4
- package/app/Actions/Dashboard/Queue/QueueWorkersAction.ts +2 -4
- package/app/Actions/Dashboard/Rbac/PermissionDestroyAction.ts +6 -6
- package/app/Actions/Dashboard/Rbac/PermissionStoreAction.ts +5 -4
- package/app/Actions/Dashboard/Rbac/PermissionsIndexAction.ts +2 -3
- package/app/Actions/Dashboard/Rbac/RoleDestroyAction.ts +6 -6
- package/app/Actions/Dashboard/Rbac/RolePermissionsShowAction.ts +6 -6
- package/app/Actions/Dashboard/Rbac/RolePermissionsSyncAction.ts +6 -9
- package/app/Actions/Dashboard/Rbac/RoleStoreAction.ts +5 -4
- package/app/Actions/Dashboard/Rbac/RolesIndexAction.ts +4 -6
- package/app/Actions/Dashboard/Rbac/UserRolesShowAction.ts +6 -7
- package/app/Actions/Dashboard/Rbac/UserRolesSyncAction.ts +6 -13
- package/app/Actions/Dashboard/Rbac/UsersListAction.ts +2 -3
- package/app/Actions/Dashboard/Rbac/rbac-response.ts +10 -0
- package/app/Actions/Dashboard/Realtime/RealtimeStatsAction.ts +2 -4
- package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +69 -55
- package/app/Actions/Dashboard/Teams/TeamInvitationDestroyAction.ts +34 -16
- package/app/Actions/Dashboard/Teams/TeamInvitationResendAction.ts +52 -29
- package/app/Actions/Dashboard/Teams/TeamInviteAction.ts +104 -73
- package/app/Actions/Dashboard/Teams/TeamMemberDestroyAction.ts +36 -17
- package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +41 -28
- package/app/Actions/Dashboard/Teams/TeamPeopleIndexAction.ts +71 -58
- package/app/Actions/Dashboard/Teams/team-invitation-delivery.ts +29 -7
- package/app/Actions/Dashboard/Teams/team-member-count.ts +4 -2
- package/app/Actions/Dashboard/Teams/team-records.test.ts +2 -0
- package/app/Actions/Dashboard/Teams/team-records.ts +2 -0
- package/app/Actions/Dashboard/dashboard-request.ts +13 -0
- package/app/Actions/Dashboard/dashboard-response.ts +16 -0
- package/app/Actions/SendWelcomeEmail.ts +1 -1
- package/app/Actions/Teams/AcceptInvitationAction.ts +74 -51
- package/app/Actions/Teams/ShowInvitationAction.ts +21 -14
- package/app/Actions/Teams/team-response.ts +23 -0
- package/app/Mail/OrderConfirmation.ts +1 -1
- package/app/Mail/SubscriptionConfirmation.ts +1 -1
- package/app/Mail/TeamInvitation.ts +1 -1
- package/app/Models/CardComment.ts +3 -4
- package/app/Models/EmailIdempotency.ts +64 -0
- package/app/Models/EmailSuppression.ts +52 -0
- package/app/Models/EmailWebhookEvent.ts +51 -0
- package/app/Models/TeamInvitation.ts +13 -0
- package/functions/dashboard-api.ts +51 -10
- package/functions/data.ts +0 -1
- package/functions/inbox.test.ts +2 -0
- package/functions/inbox.ts +27 -2
- package/functions/jobs.ts +0 -1
- package/functions/public-application-url.test.ts +13 -0
- package/functions/public-application-url.ts +27 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +1 -1
- package/resources/components/Dashboard/Auth/AccessTokens.stx +15 -0
- package/resources/components/Dashboard/Ci/CiDashboard.stx +24 -6
- package/resources/components/Dashboard/Commerce/CommerceTaxDialog.stx +1 -1
- package/resources/components/Dashboard/Commerce/ProductWaitlistDashboard.stx +8 -7
- package/resources/components/Dashboard/Commerce/RestaurantWaitlistDashboard.stx +8 -7
- package/resources/components/Dashboard/Content/BlogDashboard.stx +14 -2
- package/resources/components/Dashboard/Content/PostsDashboard.stx +7 -1
- package/resources/components/Dashboard/Data/DataRecords.stx +0 -4
- package/resources/components/Dashboard/Deployments/DeploymentDetail.stx +16 -2
- package/resources/components/Dashboard/Email/EmailNavigation.stx +5 -5
- package/resources/components/Dashboard/Email/EmailSidebar.stx +1 -1
- package/resources/components/Dashboard/Email/InboxAttachmentList.stx +53 -0
- package/resources/components/Dashboard/Email/InboxDashboard.stx +5 -4
- package/resources/components/Dashboard/Email/InboxMessageDetail.stx +6 -4
- package/resources/components/Dashboard/Jobs/JobDetail.stx +3 -2
- package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +63 -6
- package/resources/components/Dashboard/Kanban/KanbanBoardsDashboard.stx +3 -6
- package/resources/components/Dashboard/Management/PermissionsDashboard.stx +4 -4
- package/resources/components/Dashboard/Models/ModelRecordsDashboard.stx +7 -13
- package/resources/components/Dashboard/Models/ModelsOverview.stx +1 -1
- package/resources/components/Dashboard/NotFoundDashboard.stx +2 -5
- package/resources/components/Dashboard/Queries/QueryDashboard.stx +6 -2
- package/resources/components/Dashboard/Settings/AppearanceSettingsDashboard.stx +4 -4
- package/resources/components/Dashboard/UI/EmptyState.stx +2 -2
- package/resources/components/Dashboard/UI/PageLayout.stx +3 -3
- package/resources/functions/dashboard/sidebar.ts +4 -4
- package/routes/dashboard-api.ts +8 -20
- package/views/dashboard/.discovered-models.json +54 -0
- package/views/dashboard/layouts/default.stx +19 -16
- package/views/dashboard/stores/auth.ts +15 -14
- package/views/dashboard/stores/ci.ts +13 -7
- 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 {
|
|
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
|
-
*
|
|
26
|
-
*
|
|
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
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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(
|
|
180
|
-
const 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
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
const
|
|
191
|
-
const
|
|
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(
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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 => !
|
|
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 {
|
|
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(
|
|
12
|
-
|
|
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 {
|
|
20
|
+
return response.json({ message: resolved.error }, resolved.status)
|
|
19
21
|
|
|
20
|
-
const input =
|
|
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 {
|
|
28
|
+
return response.json({ message: 'Validation failed.', errors }, 422)
|
|
27
29
|
if (Object.keys(data).length === 0)
|
|
28
|
-
return {
|
|
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
|
|
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 {
|
|
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(
|
|
20
|
-
|
|
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 {
|
|
28
|
+
return response.json({ message: resolved.error }, resolved.status)
|
|
27
29
|
|
|
28
|
-
const id = parseRowId(
|
|
30
|
+
const id = parseRowId(request.getParam('id'))
|
|
29
31
|
if (id === null)
|
|
30
|
-
return {
|
|
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 =
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 (
|
|
56
|
-
return
|
|
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
|
-
|
|
105
|
-
|
|
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:
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
157
|
-
apiRoutes:
|
|
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
|
+
}
|