velocious 1.0.452 → 1.0.453

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 (68) hide show
  1. package/README.md +9 -3
  2. package/build/database/drivers/mssql/index.js +1 -1
  3. package/build/database/drivers/mysql/index.js +1 -1
  4. package/build/database/drivers/pgsql/index.js +1 -1
  5. package/build/database/drivers/sqlite/base.js +1 -1
  6. package/build/database/record/attachments/attachment-record.js +121 -0
  7. package/build/database/record/attachments/store.js +2 -0
  8. package/build/database/table-data/index.js +1 -1
  9. package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +7 -2
  10. package/build/frontend-model-controller.js +174 -55
  11. package/build/frontend-model-resource/base-resource.js +2 -1
  12. package/build/frontend-model-resource/velocious-attachment-resource.js +221 -0
  13. package/build/frontend-models/base.js +127 -1
  14. package/build/frontend-models/built-in-resources.js +32 -0
  15. package/build/frontend-models/websocket-publishers.js +13 -3
  16. package/build/routes/hooks/frontend-model-command-route-hook.js +3 -2
  17. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  18. package/build/src/database/drivers/mssql/index.js +2 -2
  19. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  20. package/build/src/database/drivers/mysql/index.js +2 -2
  21. package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
  22. package/build/src/database/drivers/pgsql/index.js +2 -2
  23. package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
  24. package/build/src/database/drivers/sqlite/base.js +2 -2
  25. package/build/src/database/record/attachments/attachment-record.d.ts +67 -0
  26. package/build/src/database/record/attachments/attachment-record.d.ts.map +1 -0
  27. package/build/src/database/record/attachments/attachment-record.js +106 -0
  28. package/build/src/database/record/attachments/store.d.ts.map +1 -1
  29. package/build/src/database/record/attachments/store.js +2 -1
  30. package/build/src/database/table-data/index.js +2 -2
  31. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
  32. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +7 -3
  33. package/build/src/frontend-model-controller.d.ts +56 -6
  34. package/build/src/frontend-model-controller.d.ts.map +1 -1
  35. package/build/src/frontend-model-controller.js +154 -52
  36. package/build/src/frontend-model-resource/base-resource.d.ts +2 -0
  37. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  38. package/build/src/frontend-model-resource/base-resource.js +3 -2
  39. package/build/src/frontend-model-resource/velocious-attachment-resource.d.ts +105 -0
  40. package/build/src/frontend-model-resource/velocious-attachment-resource.d.ts.map +1 -0
  41. package/build/src/frontend-model-resource/velocious-attachment-resource.js +185 -0
  42. package/build/src/frontend-models/base.d.ts +87 -1
  43. package/build/src/frontend-models/base.d.ts.map +1 -1
  44. package/build/src/frontend-models/base.js +112 -2
  45. package/build/src/frontend-models/built-in-resources.d.ts +18 -0
  46. package/build/src/frontend-models/built-in-resources.d.ts.map +1 -0
  47. package/build/src/frontend-models/built-in-resources.js +29 -0
  48. package/build/src/frontend-models/websocket-publishers.d.ts.map +1 -1
  49. package/build/src/frontend-models/websocket-publishers.js +14 -4
  50. package/build/src/routes/hooks/frontend-model-command-route-hook.d.ts.map +1 -1
  51. package/build/src/routes/hooks/frontend-model-command-route-hook.js +4 -3
  52. package/build/tsconfig.tsbuildinfo +1 -1
  53. package/package.json +1 -1
  54. package/src/database/drivers/mssql/index.js +1 -1
  55. package/src/database/drivers/mysql/index.js +1 -1
  56. package/src/database/drivers/pgsql/index.js +1 -1
  57. package/src/database/drivers/sqlite/base.js +1 -1
  58. package/src/database/record/attachments/attachment-record.js +121 -0
  59. package/src/database/record/attachments/store.js +2 -0
  60. package/src/database/table-data/index.js +1 -1
  61. package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +7 -2
  62. package/src/frontend-model-controller.js +174 -55
  63. package/src/frontend-model-resource/base-resource.js +2 -1
  64. package/src/frontend-model-resource/velocious-attachment-resource.js +221 -0
  65. package/src/frontend-models/base.js +127 -1
  66. package/src/frontend-models/built-in-resources.js +32 -0
  67. package/src/frontend-models/websocket-publishers.js +13 -3
  68. package/src/routes/hooks/frontend-model-command-route-hook.js +3 -2
package/README.md CHANGED
@@ -447,6 +447,7 @@ Frontend-model `where(...)` supports nested relationship descriptors (for exampl
447
447
  Frontend-model `joins(...)` supports relationship-object descriptors only (for example `Task.joins({project: {creatingUser: true}})`) and rejects raw SQL join strings.
448
448
  Frontend-model `distinct(...)` only accepts booleans (`true` by default) and is applied server-side through the backend query API.
449
449
  Frontend-model `pluck(...)` validates attribute/path descriptors against configured model metadata and does not accept SQL fragments.
450
+ Frontend-model query fields are limited to attributes exposed by the backend resource. Use `{name: "attributeName", selectedByDefault: false}` for fields that may be selected or filtered explicitly but should stay out of default payloads.
450
451
 
451
452
  When backend payloads include `__preloadedRelationships`, nested frontend-model relationships are hydrated recursively. Relationship methods can use `getRelationshipByName("relationship").loaded()` and will throw when a relationship was not preloaded.
452
453
 
@@ -546,11 +547,14 @@ For frontend models, configure `resourceConfig().attachments` and use:
546
547
  await frontendTask.update({descriptionFile: file})
547
548
  const descriptionFile = await frontendTask.descriptionFile().download()
548
549
  const descriptionFileUrl = await frontendTask.descriptionFile().url()
550
+ const descriptionFileMetadata = await frontendTask.descriptionFile().first()
551
+ const filesMetadata = await frontendTask.files().toArray()
549
552
  await frontendTask.attach(file)
550
553
  ```
551
554
 
552
555
  Frontend model attachment input does not support `{path: ...}`.
553
556
  Use `File`/`Blob`/bytes/`contentBase64` payloads instead.
557
+ Attachment metadata is exposed through the built-in `VelociousAttachment` frontend model with safe fields only: `id`, `recordType`, `recordId`, `name`, `position`, `filename`, `contentType`, `byteSize`, `createdAt`, and `updatedAt`. Storage internals such as `driver`, `storageKey`, and `contentBase64` remain hidden and non-queryable. Direct metadata queries require owner filters: `recordType`, `recordId`, and `name`.
554
558
 
555
559
  When your frontend app calls a backend on another host/port (or under a path prefix), configure transport once:
556
560
 
@@ -1002,6 +1006,8 @@ npx velocious g:migration create-tasks
1002
1006
  ```
1003
1007
 
1004
1008
  ## Write a migration
1009
+ Implicit `id` primary keys and `references(...)` columns use UUIDs by default. Use an explicit numeric `id` or reference `type` only for legacy schemas or external compatibility.
1010
+
1005
1011
  ```js
1006
1012
  import Migration from "velocious/build/src/database/migration/index.js"
1007
1013
 
@@ -1011,9 +1017,9 @@ export default class CreateEvents extends Migration {
1011
1017
  t.timestamps()
1012
1018
  })
1013
1019
 
1014
- // UUID primary key
1015
- await this.createTable("uuid_items", {id: {type: "uuid"}}, (t) => {
1016
- t.string("title", {null: false})
1020
+ // Legacy numeric primary key
1021
+ await this.createTable("legacy_events", {id: {type: "bigint"}}, (t) => {
1022
+ t.references("task", {type: "bigint"})
1017
1023
  t.timestamps()
1018
1024
  })
1019
1025
 
@@ -203,7 +203,7 @@ export default class VelociousDatabaseDriversMssql extends Base{
203
203
  * Runs primary key type.
204
204
  * @returns {string} - The primary key type.
205
205
  */
206
- primaryKeyType() { return "bigint" }
206
+ primaryKeyType() { return "uuid" }
207
207
 
208
208
  /**
209
209
  * Runs query actual.
@@ -211,7 +211,7 @@ export default class VelociousDatabaseDriversMysql extends Base{
211
211
  * Runs primary key type.
212
212
  * @returns {string} - The primary key type.
213
213
  */
214
- primaryKeyType() { return "bigint" }
214
+ primaryKeyType() { return "uuid" }
215
215
 
216
216
  /**
217
217
  * Runs retryable database error.
@@ -177,7 +177,7 @@ export default class VelociousDatabaseDriversPgsql extends Base{
177
177
  }
178
178
 
179
179
  getType() { return "pgsql" }
180
- primaryKeyType() { return "bigint" }
180
+ primaryKeyType() { return "uuid" }
181
181
 
182
182
  /**
183
183
  * Runs query actual.
@@ -269,7 +269,7 @@ export default class VelociousDatabaseDriversSqliteBase extends Base {
269
269
  * Runs primary key type.
270
270
  * @returns {string} - The type of the primary key for this driver.
271
271
  */
272
- primaryKeyType() { return "integer" } // Because bigint on SQLite doesn't support auto increment
272
+ primaryKeyType() { return "uuid" }
273
273
 
274
274
  /**
275
275
  * Runs query to sql.
@@ -0,0 +1,121 @@
1
+ // @ts-check
2
+
3
+ import DatabaseRecord from "../index.js"
4
+ import RecordAttachmentsStore from "./store.js"
5
+
6
+ const INTEGER_STRING_PATTERN = /^-?\d+$/
7
+
8
+ /** Frontend-readable metadata row for `velocious_attachments`. */
9
+ export default class VelociousAttachment extends DatabaseRecord {
10
+ /**
11
+ * Returns the backing attachment table name.
12
+ * @returns {string} - Backing attachment table name.
13
+ */
14
+ static tableName() {
15
+ return "velocious_attachments"
16
+ }
17
+
18
+ /**
19
+ * Ensures the framework-owned attachment table exists before loading metadata.
20
+ * @param {object} args - Options object.
21
+ * @param {import("../../../configuration.js").default} args.configuration - Configuration instance.
22
+ * @returns {Promise<void>} - Resolves when complete.
23
+ */
24
+ static async initializeRecord({configuration}) {
25
+ const store = new RecordAttachmentsStore({
26
+ configuration,
27
+ databaseIdentifier: this.getConfiguredDatabaseIdentifier()
28
+ })
29
+
30
+ await store.ensureReady()
31
+ await super.initializeRecord({configuration})
32
+ }
33
+
34
+ /**
35
+ * Returns the attachment id.
36
+ * @returns {string} - Attachment id.
37
+ */
38
+ id() { return this.readAttribute("id") }
39
+
40
+ /**
41
+ * Returns the owner model name.
42
+ * @returns {string} - Owner model name.
43
+ */
44
+ recordType() { return this.readAttribute("recordType") }
45
+
46
+ /**
47
+ * Returns the owner record id.
48
+ * @returns {string} - Owner record id.
49
+ */
50
+ recordId() { return this.readAttribute("recordId") }
51
+
52
+ /**
53
+ * Returns the attachment name on the owner model.
54
+ * @returns {string} - Attachment name on the owner model.
55
+ */
56
+ name() { return this.readAttribute("name") }
57
+
58
+ /**
59
+ * Returns the attachment position.
60
+ * @returns {number} - Attachment position.
61
+ */
62
+ position() { return this.readAttribute("position") }
63
+
64
+ /**
65
+ * Returns the attachment filename.
66
+ * @returns {string} - Attachment filename.
67
+ */
68
+ filename() { return this.readAttribute("filename") }
69
+
70
+ /**
71
+ * Returns the attachment content type.
72
+ * @returns {string | null} - Attachment content type.
73
+ */
74
+ contentType() { return this.readAttribute("contentType") }
75
+
76
+ /**
77
+ * Returns the attachment byte size.
78
+ * @returns {number} - Attachment byte size.
79
+ */
80
+ byteSize() { return this.safeIntegerAttribute({attributeName: "byteSize", expectedDescription: "attachment byte size"}) }
81
+
82
+ /**
83
+ * Returns the created-at timestamp in milliseconds.
84
+ * @returns {number} - Created-at timestamp in milliseconds.
85
+ */
86
+ createdAtMs() { return this.safeIntegerAttribute({attributeName: "createdAtMs", expectedDescription: "safe millisecond timestamp"}) }
87
+
88
+ /**
89
+ * Returns the updated-at timestamp in milliseconds.
90
+ * @returns {number} - Updated-at timestamp in milliseconds.
91
+ */
92
+ updatedAtMs() { return this.safeIntegerAttribute({attributeName: "updatedAtMs", expectedDescription: "safe millisecond timestamp"}) }
93
+
94
+ /**
95
+ * Returns a checked integer attribute value.
96
+ * @param {object} args - Options object.
97
+ * @param {"byteSize" | "createdAtMs" | "updatedAtMs"} args.attributeName - Integer attribute name.
98
+ * @param {string} args.expectedDescription - Description for error messages.
99
+ * @returns {number} - Safe integer value.
100
+ */
101
+ safeIntegerAttribute({attributeName, expectedDescription}) {
102
+ const value = this.readAttribute(attributeName)
103
+ let integer
104
+
105
+ if (typeof value === "number") {
106
+ integer = value
107
+ } else if (typeof value === "bigint") {
108
+ integer = Number(value)
109
+ } else if (typeof value === "string" && INTEGER_STRING_PATTERN.test(value)) {
110
+ integer = Number(value)
111
+ } else {
112
+ throw new Error(`Expected ${attributeName} to be a ${expectedDescription}`)
113
+ }
114
+
115
+ if (!Number.isSafeInteger(integer)) {
116
+ throw new Error(`Expected ${attributeName} to be a ${expectedDescription}`)
117
+ }
118
+
119
+ return integer
120
+ }
121
+ }
@@ -98,6 +98,8 @@ export default class RecordAttachmentsStore {
98
98
 
99
99
  this._readyPromise = (async () => {
100
100
  await this._withDb(async (db) => {
101
+ db.clearSchemaCache()
102
+
101
103
  if (await db.tableExists(ATTACHMENTS_TABLE)) {
102
104
  await this.ensureAttachmentStoreSchema({db})
103
105
  return
@@ -188,7 +188,7 @@ export default class TableData {
188
188
  const referenceArgs = args || {}
189
189
  const reference = new TableReference(name, referenceArgs)
190
190
  const {index, polymorphic, ...restArgs} = referenceArgs
191
- const columnArgs = Object.assign({isNewColumn: true, type: "bigint"}, restArgs)
191
+ const columnArgs = Object.assign({isNewColumn: true, type: "uuid"}, restArgs)
192
192
  const column = new TableColumn(columnName, columnArgs)
193
193
  const indexArgs = typeof index == "object" ? {unique: index.unique === true} : undefined
194
194
  const tableIndex = new TableIndex([column], indexArgs)
@@ -2,7 +2,8 @@ import BaseCommand from "../../../../../cli/base-command.js"
2
2
  import fs from "fs/promises"
3
3
  import path from "node:path"
4
4
  import * as inflection from "inflection"
5
- import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition, frontendModelResourcesForBackendProject} from "../../../../../frontend-models/resource-definition.js"
5
+ import {frontendModelResourceIsBuiltIn, frontendModelResourcesWithBuiltInsForBackendProject} from "../../../../../frontend-models/built-in-resources.js"
6
+ import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition} from "../../../../../frontend-models/resource-definition.js"
6
7
 
7
8
  /**
8
9
  * Attribute metadata used for generated frontend-model JSDoc.
@@ -110,6 +111,10 @@ export default class DbGenerateFrontendModels extends BaseCommand {
110
111
  this.validateModelConfig({availableFrontendModelClassNames, className, modelConfig, resourceClass})
111
112
 
112
113
  if (generatedModelNames.has(className)) {
114
+ if (frontendModelResourceIsBuiltIn({modelName: modelClassName, resourceDefinition: resources[modelClassName]})) {
115
+ continue
116
+ }
117
+
113
118
  throw new Error(`Duplicate frontend model definition for '${className}'`)
114
119
  }
115
120
 
@@ -191,7 +196,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
191
196
  * @returns {Record<string, import("../../../../../configuration-types.js").FrontendModelResourceDefinition>} - Resource definitions keyed by model class name.
192
197
  */
193
198
  resourcesForBackendProject(backendProject) {
194
- return frontendModelResourcesForBackendProject(backendProject)
199
+ return frontendModelResourcesWithBuiltInsForBackendProject(backendProject)
195
200
  }
196
201
 
197
202
  /**
@@ -2,8 +2,10 @@
2
2
 
3
3
  import * as inflection from "inflection"
4
4
  import Controller from "./controller.js"
5
+ import FrontendModelBaseResource from "./frontend-model-resource/base-resource.js"
5
6
  import Response from "./http-server/client/response.js"
6
- import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition, frontendModelResourcePath, frontendModelResourcesForBackendProject} from "./frontend-models/resource-definition.js"
7
+ import {frontendModelResourcesWithBuiltInsForBackendProject} from "./frontend-models/built-in-resources.js"
8
+ import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition, frontendModelResourcePath} from "./frontend-models/resource-definition.js"
7
9
  import {normalizeGroup as normalizeQueryGroup, normalizeJoins as normalizeQueryJoins, normalizePluck as normalizeQueryPluck, normalizePreload as normalizeQueryPreload, normalizeSearchOperator as normalizeQuerySearchOperator, normalizeSort as normalizeQuerySort} from "./frontend-models/query.js"
8
10
  import {assignSafeProperty, deserializeFrontendModelTransportValue, isBackendModelInstance, serializeFrontendModelTransportValue} from "./frontend-models/transport-serialization.js"
9
11
  import RoutesResolver from "./routes/resolver.js"
@@ -679,7 +681,7 @@ export default class FrontendModelController extends Controller {
679
681
  const backendProjects = this.getConfiguration().getBackendProjects()
680
682
 
681
683
  for (const backendProject of backendProjects) {
682
- const resources = frontendModelResourcesForBackendProject(backendProject)
684
+ const resources = frontendModelResourcesWithBuiltInsForBackendProject(backendProject)
683
685
 
684
686
  if (modelName && modelName.length > 0 && resources[modelName]) {
685
687
  const resourceDefinition = resources[modelName]
@@ -733,7 +735,7 @@ export default class FrontendModelController extends Controller {
733
735
  * @returns {{backendProject: import("./configuration-types.js").BackendProjectConfiguration, modelName: string, resourceClass: import("./configuration-types.js").FrontendModelResourceClassType, resourceConfiguration: import("./configuration-types.js").NormalizedFrontendModelResourceConfiguration} | null} - Frontend model resource configuration for model name.
734
736
  */
735
737
  frontendModelResourceConfigurationForBackendProjectModelName({backendProject, modelName}) {
736
- const resources = frontendModelResourcesForBackendProject(backendProject)
738
+ const resources = frontendModelResourcesWithBuiltInsForBackendProject(backendProject)
737
739
  const resourceDefinition = resources[modelName]
738
740
 
739
741
  if (!resourceDefinition) return null
@@ -1027,16 +1029,31 @@ export default class FrontendModelController extends Controller {
1027
1029
  }
1028
1030
 
1029
1031
  /**
1030
- * Runs frontend model authorized query.
1032
+ * Runs frontend model ability authorized query.
1031
1033
  * @param {"index" | "find" | "create" | "update" | "destroy" | "attach" | "download" | "url"} action - Frontend action.
1032
1034
  * @returns {import("./database/query/model-class-query.js").default<typeof import("./database/record/index.js").default>} - Authorized query for the action.
1033
1035
  */
1034
- frontendModelAuthorizedQuery(action) {
1036
+ frontendModelAbilityAuthorizedQuery(action) {
1035
1037
  const abilityAction = this.frontendModelAbilityAction(action)
1036
1038
 
1037
1039
  return this.frontendModelClass().accessibleFor(abilityAction, this.currentAbility())
1038
1040
  }
1039
1041
 
1042
+ /**
1043
+ * Runs frontend model authorized query.
1044
+ * @param {"index" | "find" | "create" | "update" | "destroy" | "attach" | "download" | "url"} action - Frontend action.
1045
+ * @returns {import("./database/query/model-class-query.js").default<typeof import("./database/record/index.js").default>} - Authorized query for the action.
1046
+ */
1047
+ frontendModelAuthorizedQuery(action) {
1048
+ const resource = this.frontendModelResourceInstance()
1049
+
1050
+ if (resource.authorizedQuery !== FrontendModelBaseResource.prototype.authorizedQuery) {
1051
+ return resource.authorizedQuery(action)
1052
+ }
1053
+
1054
+ return this.frontendModelAbilityAuthorizedQuery(action)
1055
+ }
1056
+
1040
1057
  /**
1041
1058
  * Runs frontend model primary key value.
1042
1059
  * @param {import("./database/record/index.js").default} model - Model instance.
@@ -1638,7 +1655,11 @@ export default class FrontendModelController extends Controller {
1638
1655
  modelClass,
1639
1656
  path: pluckEntry.path
1640
1657
  })
1641
- const columnName = this.resolveFrontendModelColumnName(targetModelClass, pluckEntry.column)
1658
+ const columnName = this.resolveFrontendModelQueryableColumnName({
1659
+ attributeName: pluckEntry.column,
1660
+ modelClass: targetModelClass,
1661
+ operationName: "pluck"
1662
+ })
1642
1663
 
1643
1664
  if (!columnName) {
1644
1665
  throw new Error(`Unknown pluck column "${pluckEntry.column}" for ${targetModelClass.name}`)
@@ -1717,7 +1738,11 @@ export default class FrontendModelController extends Controller {
1717
1738
  modelClass,
1718
1739
  path: search.path
1719
1740
  })
1720
- const columnName = this.resolveFrontendModelColumnName(targetModelClass, search.column)
1741
+ const columnName = this.resolveFrontendModelQueryableColumnName({
1742
+ attributeName: search.column,
1743
+ modelClass: targetModelClass,
1744
+ operationName: "search"
1745
+ })
1721
1746
 
1722
1747
  if (!columnName) {
1723
1748
  throw new Error(`Unknown search column "${search.column}" for ${targetModelClass.name}`)
@@ -1895,6 +1920,110 @@ export default class FrontendModelController extends Controller {
1895
1920
  }
1896
1921
  }
1897
1922
 
1923
+ /**
1924
+ * Runs frontend model exposed attribute names for model class.
1925
+ * @param {typeof import("./database/record/index.js").default} modelClass - Model class.
1926
+ * @returns {Set<string> | null} - Exposed attribute names, or null when no resource metadata is available.
1927
+ */
1928
+ frontendModelExposedAttributeNamesForModelClass(modelClass) {
1929
+ const frontendModelResource = this.frontendModelResourceConfigurationForModelClass(modelClass)
1930
+ const attributes = frontendModelResource?.resourceConfiguration.attributes
1931
+
1932
+ if (!attributes) return null
1933
+
1934
+ if (Array.isArray(attributes)) {
1935
+ const attributeNames = attributes
1936
+ .map((entry) => {
1937
+ if (typeof entry === "string") return entry
1938
+ if (!entry || typeof entry !== "object") return null
1939
+
1940
+ const name = /**
1941
+ * Types the following value.
1942
+ * @type {Record<string, ?>} */ (entry).name
1943
+
1944
+ return typeof name === "string" && name.length > 0 ? name : null
1945
+ })
1946
+ .filter((entry) => typeof entry === "string")
1947
+
1948
+ return new Set(attributeNames)
1949
+ }
1950
+
1951
+ if (typeof attributes === "object") {
1952
+ return new Set(Object.keys(attributes))
1953
+ }
1954
+
1955
+ return null
1956
+ }
1957
+
1958
+ /**
1959
+ * Resolves a frontend-supplied key to its canonical model attribute name.
1960
+ * @param {typeof import("./database/record/index.js").default} modelClass - Model class.
1961
+ * @param {string} key - Frontend key or raw column key.
1962
+ * @returns {string | null} - Canonical attribute name.
1963
+ */
1964
+ frontendModelAttributeNameForKey(modelClass, key) {
1965
+ const resolvedAttributeName = modelClass.resolveAttributeName(key)
1966
+
1967
+ if (resolvedAttributeName) return resolvedAttributeName
1968
+
1969
+ const columnAttributeName = modelClass.getColumnNameToAttributeNameMap()[key]
1970
+
1971
+ return columnAttributeName || null
1972
+ }
1973
+
1974
+ /**
1975
+ * Checks if a frontend-supplied attribute is exposed by the resource.
1976
+ * @param {object} args - Args.
1977
+ * @param {string} args.attributeName - Requested attribute name.
1978
+ * @param {typeof import("./database/record/index.js").default} args.modelClass - Model class.
1979
+ * @returns {boolean} - Whether the resource permits the attribute.
1980
+ */
1981
+ frontendModelAttributeIsExposed({attributeName, modelClass}) {
1982
+ const exposedAttributeNames = this.frontendModelExposedAttributeNamesForModelClass(modelClass)
1983
+
1984
+ if (!exposedAttributeNames) return true
1985
+
1986
+ return exposedAttributeNames.has(attributeName)
1987
+ }
1988
+
1989
+ /**
1990
+ * Validates a selected frontend-model attribute list against resource metadata.
1991
+ * @param {object} args - Args.
1992
+ * @param {string[]} args.attributeNames - Selected attribute names.
1993
+ * @param {typeof import("./database/record/index.js").default} args.modelClass - Model class.
1994
+ * @param {"select" | "selectsExtra"} args.operationName - Selection operation.
1995
+ * @returns {string[]} - Validated selected attribute names.
1996
+ */
1997
+ validateFrontendModelSelectedAttributes({attributeNames, modelClass, operationName}) {
1998
+ for (const attributeName of attributeNames) {
1999
+ if (this.frontendModelAttributeIsExposed({attributeName, modelClass})) continue
2000
+
2001
+ throw new Error(`Unknown ${operationName} attribute "${attributeName}" for ${modelClass.name}`)
2002
+ }
2003
+
2004
+ return attributeNames
2005
+ }
2006
+
2007
+ /**
2008
+ * Resolves a user-queryable frontend attribute to a database column.
2009
+ * @param {object} args - Args.
2010
+ * @param {string} args.attributeName - Requested attribute name.
2011
+ * @param {typeof import("./database/record/index.js").default} args.modelClass - Model class.
2012
+ * @param {"group" | "pluck" | "search" | "sort" | "where"} args.operationName - Query operation.
2013
+ * @returns {string | undefined} - Resolved column name.
2014
+ */
2015
+ resolveFrontendModelQueryableColumnName({attributeName, modelClass, operationName}) {
2016
+ void operationName
2017
+
2018
+ const resolvedAttributeName = this.frontendModelAttributeNameForKey(modelClass, attributeName)
2019
+
2020
+ if (resolvedAttributeName && !this.frontendModelAttributeIsExposed({attributeName: resolvedAttributeName, modelClass})) {
2021
+ return undefined
2022
+ }
2023
+
2024
+ return this.resolveFrontendModelColumnName(modelClass, attributeName)
2025
+ }
2026
+
1898
2027
  /**
1899
2028
  * Resolves a key that may be either a camelCase attribute name or a raw DB
1900
2029
  * column name to its canonical column name. Returns `undefined` when the
@@ -1925,7 +2054,11 @@ export default class FrontendModelController extends Controller {
1925
2054
  */
1926
2055
  applyFrontendModelWhereForPath({modelClass, path, query, where}) {
1927
2056
  for (const [attributeName, value] of Object.entries(where)) {
1928
- const columnName = this.resolveFrontendModelColumnName(modelClass, attributeName)
2057
+ const columnName = this.resolveFrontendModelQueryableColumnName({
2058
+ attributeName,
2059
+ modelClass,
2060
+ operationName: "where"
2061
+ })
1929
2062
 
1930
2063
  if (columnName) {
1931
2064
  this.ensureFrontendModelJoinPath({path, query})
@@ -2049,7 +2182,11 @@ export default class FrontendModelController extends Controller {
2049
2182
  modelClass,
2050
2183
  path: group.path
2051
2184
  })
2052
- const columnName = this.resolveFrontendModelColumnName(targetModelClass, group.column)
2185
+ const columnName = this.resolveFrontendModelQueryableColumnName({
2186
+ attributeName: group.column,
2187
+ modelClass: targetModelClass,
2188
+ operationName: "group"
2189
+ })
2053
2190
 
2054
2191
  if (!columnName) {
2055
2192
  throw new Error(`Unknown group column "${group.column}" for ${targetModelClass.name}`)
@@ -2162,7 +2299,11 @@ export default class FrontendModelController extends Controller {
2162
2299
  const translatedAttributeNames = Object.keys(translatedAttributesMap)
2163
2300
  const isTranslatedSortAttribute = translatedAttributeNames.includes(sort.column)
2164
2301
 
2165
- const columnName = this.resolveFrontendModelColumnName(targetModelClass, sort.column)
2302
+ const columnName = this.resolveFrontendModelQueryableColumnName({
2303
+ attributeName: sort.column,
2304
+ modelClass: targetModelClass,
2305
+ operationName: "sort"
2306
+ })
2166
2307
  const direction = sort.direction.toUpperCase()
2167
2308
 
2168
2309
  if (isTranslatedSortAttribute) {
@@ -2239,7 +2380,15 @@ export default class FrontendModelController extends Controller {
2239
2380
 
2240
2381
  if (!select) return null
2241
2382
 
2242
- return select[modelClass.getModelName()] || null
2383
+ const selectedAttributes = select[modelClass.getModelName()] || null
2384
+
2385
+ if (!selectedAttributes) return null
2386
+
2387
+ return this.validateFrontendModelSelectedAttributes({
2388
+ attributeNames: selectedAttributes,
2389
+ modelClass,
2390
+ operationName: "select"
2391
+ })
2243
2392
  }
2244
2393
 
2245
2394
  /**
@@ -2252,7 +2401,15 @@ export default class FrontendModelController extends Controller {
2252
2401
 
2253
2402
  if (!selectsExtra) return null
2254
2403
 
2255
- return selectsExtra[modelClass.getModelName()] || null
2404
+ const extraAttributes = selectsExtra[modelClass.getModelName()] || null
2405
+
2406
+ if (!extraAttributes) return null
2407
+
2408
+ return this.validateFrontendModelSelectedAttributes({
2409
+ attributeNames: extraAttributes,
2410
+ modelClass,
2411
+ operationName: "selectsExtra"
2412
+ })
2256
2413
  }
2257
2414
 
2258
2415
  /**
@@ -2319,42 +2476,6 @@ export default class FrontendModelController extends Controller {
2319
2476
  return null
2320
2477
  }
2321
2478
 
2322
- /**
2323
- * Runs frontend model non default attributes for model class.
2324
- * @param {typeof import("./database/record/index.js").default} modelClass - Model class.
2325
- * @returns {string[]} - Attribute names explicitly marked selectedByDefault: false.
2326
- */
2327
- frontendModelNonDefaultAttributesForModelClass(modelClass) {
2328
- const frontendModelResource = this.frontendModelResourceConfigurationForModelClass(modelClass)
2329
- const attributes = frontendModelResource?.resourceConfiguration.attributes
2330
-
2331
- if (!attributes) return []
2332
-
2333
- if (Array.isArray(attributes)) {
2334
- return attributes
2335
- .filter((entry) => {
2336
- if (typeof entry !== "object" || !entry) return false
2337
-
2338
- return /** @type {Record<string, ?>} */ (entry).selectedByDefault === false
2339
- })
2340
- .map((entry) => /**
2341
- * Types the following value.
2342
- * @type {Record<string, ?>} */ (/**
2343
- * Types the following value.
2344
- * @type {?} */ (entry)).name)
2345
- }
2346
-
2347
- if (typeof attributes === "object") {
2348
- return Object.entries(attributes)
2349
- .filter(([, config]) => typeof config === "object" && config && /**
2350
- * Types the following value.
2351
- * @type {Record<string, ?>} */ (config).selectedByDefault === false)
2352
- .map(([name]) => name)
2353
- }
2354
-
2355
- return []
2356
- }
2357
-
2358
2479
  /**
2359
2480
  * Runs serialize frontend model attributes.
2360
2481
  * @param {import("./database/record/index.js").default} model - Model instance.
@@ -2452,12 +2573,10 @@ export default class FrontendModelController extends Controller {
2452
2573
  return modelAttributes
2453
2574
  }
2454
2575
 
2455
- const excludedAttributes = this.frontendModelNonDefaultAttributesForModelClass(modelClass)
2456
- const serializedAttributes = {...modelAttributes}
2457
-
2458
- for (const excludedName of excludedAttributes) {
2459
- delete serializedAttributes[excludedName]
2460
- }
2576
+ /**
2577
+ * Serialized attributes.
2578
+ * @type {Record<string, ?>} */
2579
+ const serializedAttributes = {}
2461
2580
 
2462
2581
  for (const attributeName of defaultAttributes) {
2463
2582
  if (!attributeExists(attributeName)) continue
@@ -2499,7 +2618,7 @@ export default class FrontendModelController extends Controller {
2499
2618
  * @type {typeof import("./database/record/index.js").default} */ (model.constructor).getModelName()
2500
2619
 
2501
2620
  for (const backendProject of backendProjects) {
2502
- const resources = frontendModelResourcesForBackendProject(backendProject)
2621
+ const resources = frontendModelResourcesWithBuiltInsForBackendProject(backendProject)
2503
2622
  const resourceDefinition = resources[modelClassName]
2504
2623
  const resourceClass = resourceDefinition ? frontendModelResourceClassFromDefinition(resourceDefinition) : null
2505
2624
 
@@ -14,6 +14,7 @@ import isPlainObject from "../utils/plain-object.js"
14
14
  * @typedef {import("../controller.js").default & {
15
15
  * currentAbility: () => import("../authorization/ability.js").default | undefined,
16
16
  * frontendModelAbilityAction: (action: FrontendModelResourceAction) => string,
17
+ * frontendModelAbilityAuthorizedQuery: (action: FrontendModelResourceAction) => import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>,
17
18
  * frontendModelAuthorizedQuery: (action: FrontendModelResourceAction) => import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>,
18
19
  * frontendModelIndexQuery: () => import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>,
19
20
  * frontendModelParams: () => import("../configuration-types.js").VelociousParams,
@@ -393,7 +394,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
393
394
  */
394
395
  authorizedQuery(action) {
395
396
  // Narrows the controller query to this resource's model class.
396
- return /** @type {import("../database/query/model-class-query.js").default<TModelClass>} */ (this.typedControllerInstance().frontendModelAuthorizedQuery(action))
397
+ return /** @type {import("../database/query/model-class-query.js").default<TModelClass>} */ (this.typedControllerInstance().frontendModelAbilityAuthorizedQuery(action))
397
398
  }
398
399
 
399
400