velocious 1.0.445 → 1.0.447

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 (89) hide show
  1. package/README.md +1 -1
  2. package/build/configuration-types.js +2 -2
  3. package/build/database/pool/async-tracked-multi-connection.js +3 -1
  4. package/build/database/record/index.js +38 -38
  5. package/build/environment-handlers/node/cli/commands/generate/base-models.js +67 -1
  6. package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +169 -0
  7. package/build/frontend-model-controller.js +44 -12
  8. package/build/frontend-model-resource/base-resource.js +519 -129
  9. package/build/frontend-models/base.js +417 -203
  10. package/build/frontend-models/preloader.js +7 -7
  11. package/build/frontend-models/query.js +18 -18
  12. package/build/frontend-models/use-created-event.js +1 -1
  13. package/build/frontend-models/use-destroyed-event.js +1 -1
  14. package/build/frontend-models/use-model-class-event.js +1 -1
  15. package/build/frontend-models/use-updated-event.js +1 -1
  16. package/build/frontend-models/websocket-channel.js +39 -3
  17. package/build/routes/resolver.js +17 -14
  18. package/build/src/configuration-types.d.ts +6 -6
  19. package/build/src/configuration-types.js +3 -3
  20. package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
  21. package/build/src/database/pool/async-tracked-multi-connection.js +5 -2
  22. package/build/src/database/record/index.d.ts +38 -38
  23. package/build/src/database/record/index.d.ts.map +1 -1
  24. package/build/src/database/record/index.js +39 -39
  25. package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts +13 -0
  26. package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts.map +1 -1
  27. package/build/src/environment-handlers/node/cli/commands/generate/base-models.js +59 -2
  28. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts +74 -0
  29. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
  30. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +155 -1
  31. package/build/src/frontend-model-controller.d.ts +2 -1
  32. package/build/src/frontend-model-controller.d.ts.map +1 -1
  33. package/build/src/frontend-model-controller.js +38 -14
  34. package/build/src/frontend-model-resource/base-resource.d.ts +196 -21
  35. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  36. package/build/src/frontend-model-resource/base-resource.js +467 -112
  37. package/build/src/frontend-models/base.d.ts +232 -149
  38. package/build/src/frontend-models/base.d.ts.map +1 -1
  39. package/build/src/frontend-models/base.js +371 -201
  40. package/build/src/frontend-models/preloader.d.ts +10 -10
  41. package/build/src/frontend-models/preloader.d.ts.map +1 -1
  42. package/build/src/frontend-models/preloader.js +8 -8
  43. package/build/src/frontend-models/query.d.ts +8 -8
  44. package/build/src/frontend-models/query.d.ts.map +1 -1
  45. package/build/src/frontend-models/query.js +19 -19
  46. package/build/src/frontend-models/use-created-event.d.ts +2 -2
  47. package/build/src/frontend-models/use-created-event.d.ts.map +1 -1
  48. package/build/src/frontend-models/use-created-event.js +2 -2
  49. package/build/src/frontend-models/use-destroyed-event.d.ts +1 -1
  50. package/build/src/frontend-models/use-destroyed-event.d.ts.map +1 -1
  51. package/build/src/frontend-models/use-destroyed-event.js +2 -2
  52. package/build/src/frontend-models/use-model-class-event.d.ts +1 -1
  53. package/build/src/frontend-models/use-model-class-event.d.ts.map +1 -1
  54. package/build/src/frontend-models/use-model-class-event.js +2 -2
  55. package/build/src/frontend-models/use-updated-event.d.ts +1 -1
  56. package/build/src/frontend-models/use-updated-event.d.ts.map +1 -1
  57. package/build/src/frontend-models/use-updated-event.js +2 -2
  58. package/build/src/frontend-models/websocket-channel.d.ts +8 -0
  59. package/build/src/frontend-models/websocket-channel.d.ts.map +1 -1
  60. package/build/src/frontend-models/websocket-channel.js +35 -4
  61. package/build/src/routes/resolver.d.ts.map +1 -1
  62. package/build/src/routes/resolver.js +7 -4
  63. package/build/src/utils/model-scope.d.ts +4 -4
  64. package/build/src/utils/model-scope.d.ts.map +1 -1
  65. package/build/src/utils/model-scope.js +3 -3
  66. package/build/src/utils/ransack.d.ts +1 -1
  67. package/build/src/utils/ransack.d.ts.map +1 -1
  68. package/build/src/utils/ransack.js +2 -2
  69. package/build/utils/model-scope.js +2 -2
  70. package/build/utils/ransack.js +1 -1
  71. package/package.json +1 -1
  72. package/src/configuration-types.js +2 -2
  73. package/src/database/pool/async-tracked-multi-connection.js +3 -1
  74. package/src/database/record/index.js +38 -38
  75. package/src/environment-handlers/node/cli/commands/generate/base-models.js +67 -1
  76. package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +169 -0
  77. package/src/frontend-model-controller.js +44 -12
  78. package/src/frontend-model-resource/base-resource.js +519 -129
  79. package/src/frontend-models/base.js +417 -203
  80. package/src/frontend-models/preloader.js +7 -7
  81. package/src/frontend-models/query.js +18 -18
  82. package/src/frontend-models/use-created-event.js +1 -1
  83. package/src/frontend-models/use-destroyed-event.js +1 -1
  84. package/src/frontend-models/use-model-class-event.js +1 -1
  85. package/src/frontend-models/use-updated-event.js +1 -1
  86. package/src/frontend-models/websocket-channel.js +39 -3
  87. package/src/routes/resolver.js +17 -14
  88. package/src/utils/model-scope.js +2 -2
  89. package/src/utils/ransack.js +1 -1
package/README.md CHANGED
@@ -1453,7 +1453,7 @@ database: {
1453
1453
  }
1454
1454
  ```
1455
1455
 
1456
- `pool.max` caps live async-tracked connections for that pool. When the cap is reached, new checkouts wait until a matching checked-in connection can be handed over or capacity is freed. The built-in debug endpoint reports each in-use connection's `checkedOutForMs`, each idle connection's `idleForMs`, and queued `pendingCheckouts[].waitingForMs` so production diagnostics can distinguish long-held checkouts from pool-capacity waits.
1456
+ `pool.max` caps live async-tracked connections for that pool and defaults to `10` when omitted. When the cap is reached, new checkouts wait until a matching checked-in connection can be handed over or capacity is freed. Set `pool.max` to `null` only when a process is deliberately allowed to open an unbounded number of database connections. The built-in debug endpoint reports each in-use connection's `checkedOutForMs`, each idle connection's `idleForMs`, and queued `pendingCheckouts[].waitingForMs` so production diagnostics can distinguish long-held checkouts from pool-capacity waits.
1457
1457
 
1458
1458
  # Websockets
1459
1459
 
@@ -44,7 +44,7 @@
44
44
  * @property {boolean} [options.trustServerCertificate] - Whether to trust the server certificate (MSSQL).
45
45
  * @property {string} [password] - Password for the SQL user.
46
46
  * @property {object} [pool] - Connection pool configuration.
47
- * @property {number} [pool.max] - Maximum number of connections.
47
+ * @property {number | null} [pool.max] - Maximum number of connections. Set null to disable the cap.
48
48
  * @property {number} [pool.min] - Minimum number of connections.
49
49
  * @property {number} [pool.idleTimeoutMillis] - Idle timeout before releasing a connection.
50
50
  * @property {string} [server] - SQL server hostname.
@@ -54,7 +54,7 @@
54
54
  /**
55
55
  * @typedef {object} DatabasePoolConfiguration
56
56
  * @property {number | null} [idleTimeoutMillis] - Idle timeout before closing a checked-in async-tracked connection. Set null to disable idle reaping. Default: 5000.
57
- * @property {number} [max] - Maximum live async-tracked connections for this pool. Extra checkouts wait until a matching connection is checked in or capacity is freed.
57
+ * @property {number | null} [max] - Maximum live async-tracked connections for this pool. Defaults to 10. Extra checkouts wait until a matching connection is checked in or capacity is freed. Set null to disable the cap.
58
58
  */
59
59
 
60
60
  /**
@@ -6,6 +6,7 @@ import BasePool, {POOL_CONFIGURATION_KEY} from "./base.js"
6
6
  export const CLOSED_CONNECTION = Symbol("velociousClosedConnection")
7
7
  const IDLE_CONNECTION_CHECKED_IN_AT = Symbol("velociousIdleConnectionCheckedInAt")
8
8
  const CONNECTION_CHECKED_OUT_AT = Symbol("velociousConnectionCheckedOutAt")
9
+ const DEFAULT_MAX_CONNECTIONS = 10
9
10
  const DEFAULT_IDLE_TIMEOUT_MILLIS = 5000
10
11
 
11
12
  /**
@@ -285,9 +286,10 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
285
286
  maxConnections() {
286
287
  const value = this.getConfiguration().pool?.max
287
288
 
289
+ if (value === null) return
288
290
  if (this.validMaxConnections(value)) return value
289
291
 
290
- return
292
+ return DEFAULT_MAX_CONNECTIONS
291
293
  }
292
294
 
293
295
  /**
@@ -7,13 +7,13 @@
7
7
 
8
8
  /**
9
9
  * LifecycleCallbackType type.
10
- * @template {VelociousDatabaseRecord} [T=VelociousDatabaseRecord]
10
+ * @template [T=VelociousDatabaseRecord]
11
11
  * @typedef {((model: T) => void | Promise<void>) | string} LifecycleCallbackType
12
12
  */
13
13
 
14
14
  /**
15
15
  * Model class constructor type used for static `this` typing.
16
- * @template {VelociousDatabaseRecord} T
16
+ * @template T
17
17
  * @typedef {{new (changes?: Record<string, unknown>): T}} ModelConstructor
18
18
  */
19
19
 
@@ -507,101 +507,101 @@ class VelociousDatabaseRecord {
507
507
 
508
508
  /**
509
509
  * Runs before validation.
510
- * @template {typeof VelociousDatabaseRecord} MC
511
- * @this {MC}
512
- * @param {LifecycleCallbackType<InstanceType<MC>>} callback - Callback function or instance method name.
510
+ * @template R
511
+ * @this {ModelConstructor<R>}
512
+ * @param {LifecycleCallbackType<R>} callback - Callback function or instance method name.
513
513
  * @returns {void}
514
514
  */
515
515
  static beforeValidation(callback) {
516
- this.registerLifecycleCallback("beforeValidation", /** @type {LifecycleCallbackType} */ (callback))
516
+ VelociousDatabaseRecord.registerLifecycleCallback.call(this, "beforeValidation", /** @type {LifecycleCallbackType} */ (callback))
517
517
  }
518
518
 
519
519
  /**
520
520
  * Runs before save.
521
- * @template {typeof VelociousDatabaseRecord} MC
522
- * @this {MC}
523
- * @param {LifecycleCallbackType<InstanceType<MC>>} callback - Callback function or instance method name.
521
+ * @template R
522
+ * @this {ModelConstructor<R>}
523
+ * @param {LifecycleCallbackType<R>} callback - Callback function or instance method name.
524
524
  * @returns {void}
525
525
  */
526
526
  static beforeSave(callback) {
527
- this.registerLifecycleCallback("beforeSave", /** @type {LifecycleCallbackType} */ (callback))
527
+ VelociousDatabaseRecord.registerLifecycleCallback.call(this, "beforeSave", /** @type {LifecycleCallbackType} */ (callback))
528
528
  }
529
529
 
530
530
  /**
531
531
  * Runs before create.
532
- * @template {typeof VelociousDatabaseRecord} MC
533
- * @this {MC}
534
- * @param {LifecycleCallbackType<InstanceType<MC>>} callback - Callback function or instance method name.
532
+ * @template R
533
+ * @this {ModelConstructor<R>}
534
+ * @param {LifecycleCallbackType<R>} callback - Callback function or instance method name.
535
535
  * @returns {void}
536
536
  */
537
537
  static beforeCreate(callback) {
538
- this.registerLifecycleCallback("beforeCreate", /** @type {LifecycleCallbackType} */ (callback))
538
+ VelociousDatabaseRecord.registerLifecycleCallback.call(this, "beforeCreate", /** @type {LifecycleCallbackType} */ (callback))
539
539
  }
540
540
 
541
541
  /**
542
542
  * Runs before update.
543
- * @template {typeof VelociousDatabaseRecord} MC
544
- * @this {MC}
545
- * @param {LifecycleCallbackType<InstanceType<MC>>} callback - Callback function or instance method name.
543
+ * @template R
544
+ * @this {ModelConstructor<R>}
545
+ * @param {LifecycleCallbackType<R>} callback - Callback function or instance method name.
546
546
  * @returns {void}
547
547
  */
548
548
  static beforeUpdate(callback) {
549
- this.registerLifecycleCallback("beforeUpdate", /** @type {LifecycleCallbackType} */ (callback))
549
+ VelociousDatabaseRecord.registerLifecycleCallback.call(this, "beforeUpdate", /** @type {LifecycleCallbackType} */ (callback))
550
550
  }
551
551
 
552
552
  /**
553
553
  * Runs before destroy.
554
- * @template {typeof VelociousDatabaseRecord} MC
555
- * @this {MC}
556
- * @param {LifecycleCallbackType<InstanceType<MC>>} callback - Callback function or instance method name.
554
+ * @template R
555
+ * @this {ModelConstructor<R>}
556
+ * @param {LifecycleCallbackType<R>} callback - Callback function or instance method name.
557
557
  * @returns {void}
558
558
  */
559
559
  static beforeDestroy(callback) {
560
- this.registerLifecycleCallback("beforeDestroy", /** @type {LifecycleCallbackType} */ (callback))
560
+ VelociousDatabaseRecord.registerLifecycleCallback.call(this, "beforeDestroy", /** @type {LifecycleCallbackType} */ (callback))
561
561
  }
562
562
 
563
563
  /**
564
564
  * Runs after save.
565
- * @template {typeof VelociousDatabaseRecord} MC
566
- * @this {MC}
567
- * @param {LifecycleCallbackType<InstanceType<MC>>} callback - Callback function or instance method name.
565
+ * @template R
566
+ * @this {ModelConstructor<R>}
567
+ * @param {LifecycleCallbackType<R>} callback - Callback function or instance method name.
568
568
  * @returns {void}
569
569
  */
570
570
  static afterSave(callback) {
571
- this.registerLifecycleCallback("afterSave", /** @type {LifecycleCallbackType} */ (callback))
571
+ VelociousDatabaseRecord.registerLifecycleCallback.call(this, "afterSave", /** @type {LifecycleCallbackType} */ (callback))
572
572
  }
573
573
 
574
574
  /**
575
575
  * Runs after create.
576
- * @template {typeof VelociousDatabaseRecord} MC
577
- * @this {MC}
578
- * @param {LifecycleCallbackType<InstanceType<MC>>} callback - Callback function or instance method name.
576
+ * @template R
577
+ * @this {ModelConstructor<R>}
578
+ * @param {LifecycleCallbackType<R>} callback - Callback function or instance method name.
579
579
  * @returns {void}
580
580
  */
581
581
  static afterCreate(callback) {
582
- this.registerLifecycleCallback("afterCreate", /** @type {LifecycleCallbackType} */ (callback))
582
+ VelociousDatabaseRecord.registerLifecycleCallback.call(this, "afterCreate", /** @type {LifecycleCallbackType} */ (callback))
583
583
  }
584
584
 
585
585
  /**
586
586
  * Runs after update.
587
- * @template {typeof VelociousDatabaseRecord} MC
588
- * @this {MC}
589
- * @param {LifecycleCallbackType<InstanceType<MC>>} callback - Callback function or instance method name.
587
+ * @template R
588
+ * @this {ModelConstructor<R>}
589
+ * @param {LifecycleCallbackType<R>} callback - Callback function or instance method name.
590
590
  * @returns {void}
591
591
  */
592
592
  static afterUpdate(callback) {
593
- this.registerLifecycleCallback("afterUpdate", /** @type {LifecycleCallbackType} */ (callback))
593
+ VelociousDatabaseRecord.registerLifecycleCallback.call(this, "afterUpdate", /** @type {LifecycleCallbackType} */ (callback))
594
594
  }
595
595
 
596
596
  /**
597
597
  * Runs after destroy.
598
- * @template {typeof VelociousDatabaseRecord} MC
599
- * @this {MC}
600
- * @param {LifecycleCallbackType<InstanceType<MC>>} callback - Callback function or instance method name.
598
+ * @template R
599
+ * @this {ModelConstructor<R>}
600
+ * @param {LifecycleCallbackType<R>} callback - Callback function or instance method name.
601
601
  * @returns {void}
602
602
  */
603
603
  static afterDestroy(callback) {
604
- this.registerLifecycleCallback("afterDestroy", /** @type {LifecycleCallbackType} */ (callback))
604
+ VelociousDatabaseRecord.registerLifecycleCallback.call(this, "afterDestroy", /** @type {LifecycleCallbackType} */ (callback))
605
605
  }
606
606
 
607
607
  /**
@@ -115,12 +115,48 @@ export default class DbGenerateModel extends BaseCommand {
115
115
  velociousPath = "velocious/build/src"
116
116
  }
117
117
 
118
+ const columns = await table.getColumns()
119
+ const writeAttributeTypeName = `${modelNameCamelized}WriteAttributes`
120
+ const nestedWriteAttributes = this.nestedWriteAttributesForModel({modelClass})
121
+
118
122
  fileContent += `import DatabaseRecord from "${velociousPath}/database/record/index.js"\n\n`
123
+ fileContent += "/**\n"
124
+ fileContent += ` * Attributes accepted when creating or updating ${modelNameCamelized} records.\n`
125
+ fileContent += ` * @typedef {object} ${writeAttributeTypeName}\n`
126
+ for (const column of columns) {
127
+ const deburredColumnName = deburrColumnName(column.getName())
128
+ const camelizedColumnName = inflection.camelize(deburredColumnName, true)
129
+ const setterJsdocType = this.jsDocSetterTypeFromColumn(column, modelClass)
130
+
131
+ if (setterJsdocType) {
132
+ fileContent += ` * @property {${setterJsdocType}${column.getNull() ? " | null" : ""}} [${camelizedColumnName}] - Value for the ${camelizedColumnName} attribute.\n`
133
+ }
134
+ }
135
+ for (const nestedWriteAttribute of nestedWriteAttributes) {
136
+ fileContent += ` * @property {${nestedWriteAttribute.propertyType}} [${nestedWriteAttribute.propertyName}] - Nested ${nestedWriteAttribute.relationshipName} attributes.\n`
137
+ }
138
+ fileContent += " */\n\n"
119
139
 
120
140
  const hasManyRelationFilePath = `${velociousPath}/database/record/instance-relationships/has-many.js`
121
141
 
122
142
  fileContent += `export default class ${modelNameCamelized}Base extends DatabaseRecord {\n`
123
143
 
144
+ fileContent += " /**\n"
145
+ fileContent += ` * Creates a ${modelNameCamelized} record.\n`
146
+ fileContent += ` * @template {typeof ${modelNameCamelized}Base} T\n`
147
+ fileContent += " * @this {T}\n"
148
+ fileContent += ` * @param {${writeAttributeTypeName}} [attributes] - Attributes for the new record.\n`
149
+ fileContent += " * @returns {Promise<InstanceType<T>>} - Persisted record.\n"
150
+ fileContent += " */\n"
151
+ fileContent += " static async create(attributes) { return /** @type {Promise<InstanceType<T>>} */ (super.create(attributes)) }\n\n"
152
+
153
+ fileContent += " /**\n"
154
+ fileContent += ` * Updates this ${modelNameCamelized} record.\n`
155
+ fileContent += ` * @param {${writeAttributeTypeName}} attributes - Attributes to assign before saving.\n`
156
+ fileContent += " * @returns {Promise<void>} - Resolves when the record is saved.\n"
157
+ fileContent += " */\n"
158
+ fileContent += " async update(attributes) { return await super.update(attributes) }\n\n"
159
+
124
160
  // --- getModelClass() override (fixes polymorphic typing in JS/JSDoc) ---
125
161
  if (await fileExists(sourceModelFullFilePath)) {
126
162
  // Model file exists (e.g. src/models/ticket.js) → return typeof Ticket
@@ -138,7 +174,6 @@ export default class DbGenerateModel extends BaseCommand {
138
174
  fileContent += ` getModelClass() { return /** @type {typeof ${modelNameCamelized}Base} */ (this.constructor) }\n\n`
139
175
  }
140
176
 
141
- const columns = await table.getColumns()
142
177
  let methodsCount = 0
143
178
 
144
179
  for (const column of columns) {
@@ -416,4 +451,35 @@ export default class DbGenerateModel extends BaseCommand {
416
451
 
417
452
  return this.jsDocTypeFromColumn(column, modelClass)
418
453
  }
454
+
455
+ /**
456
+ * Runs nested write attributes for model.
457
+ * @param {object} args - Arguments.
458
+ * @param {typeof import("../../../../../database/record/index.js").default} args.modelClass - Model class.
459
+ * @returns {Array<{propertyName: string, propertyType: string, relationshipName: string}>} - Nested write attributes.
460
+ */
461
+ nestedWriteAttributesForModel({modelClass}) {
462
+ const acceptedNestedAttributes = modelClass._acceptedNestedAttributes || {}
463
+ const nestedWriteAttributes = []
464
+
465
+ for (const relationshipName of Object.keys(acceptedNestedAttributes)) {
466
+ const relationship = modelClass.getRelationshipByName(relationshipName)
467
+ const relationshipType = relationship.getType()
468
+ const targetModelClass = relationship.getTargetModelClass()
469
+
470
+ if (!targetModelClass) throw new Error(`Relationship '${relationshipName}' on '${modelClass.getModelName()}' has no target model class`)
471
+
472
+ const targetModelFileName = inflection.dasherize(inflection.underscore(targetModelClass.getModelName()))
473
+ const targetWriteTypeName = `${inflection.camelize(targetModelClass.getModelName().replaceAll("-", "_"))}WriteAttributes`
474
+ const nestedType = `import("./${targetModelFileName}.js").${targetWriteTypeName}${acceptedNestedAttributes[relationshipName]?.allowDestroy ? " & {_destroy?: boolean}" : ""}`
475
+
476
+ nestedWriteAttributes.push({
477
+ propertyName: `${relationshipName}Attributes`,
478
+ propertyType: relationshipType == "hasMany" ? `Array<${nestedType}>` : nestedType,
479
+ relationshipName
480
+ })
481
+ }
482
+
483
+ return nestedWriteAttributes
484
+ }
419
485
  }
@@ -228,7 +228,12 @@ export default class DbGenerateFrontendModels extends BaseCommand {
228
228
  ? modelConfig.attachments
229
229
  : {}
230
230
  const attributesTypeName = `${className}Attributes`
231
+ const createAttributesTypeName = `${className}CreateAttributes`
232
+ const updateAttributesTypeName = `${className}UpdateAttributes`
231
233
  const attributeNames = attributes.map((attribute) => attribute.name)
234
+ const permittedCreateParams = this.permittedParamsForGenerator(resourceClass || null, "create")
235
+ const permittedUpdateParams = this.permittedParamsForGenerator(resourceClass || null, "update")
236
+ const nestedWriteTypes = this.nestedWriteTypesForModel({className, permittedParams: permittedCreateParams.concat(permittedUpdateParams), relationships})
232
237
  const builtInCollectionCommands = {
233
238
  create: modelConfig.builtInCollectionCommands.create || "create",
234
239
  index: modelConfig.builtInCollectionCommands.index || "index"
@@ -268,8 +273,32 @@ export default class DbGenerateFrontendModels extends BaseCommand {
268
273
  fileContent += ` * @property {${attribute.jsDocType}} ${attribute.name} - Attribute value.\n`
269
274
  }
270
275
  fileContent += " */\n"
276
+ for (const nestedWriteType of nestedWriteTypes) {
277
+ fileContent += "/**\n"
278
+ fileContent += ` * Attributes accepted for nested ${nestedWriteType.relationshipName} writes.\n`
279
+ fileContent += ` * @typedef {object} ${nestedWriteType.typeName}\n`
280
+ for (const nestedAttribute of nestedWriteType.attributes) {
281
+ fileContent += ` * @property {${nestedAttribute.type}} [${nestedAttribute.name}] - Nested ${nestedAttribute.name} value.\n`
282
+ }
283
+ fileContent += " */\n"
284
+ }
285
+ fileContent += this.writeAttributesTypedef({attributes, attributesTypeName, nestedWriteTypes, permittedParams: permittedCreateParams, typeName: createAttributesTypeName})
286
+ fileContent += this.writeAttributesTypedef({attributes, attributesTypeName, nestedWriteTypes, permittedParams: permittedUpdateParams, typeName: updateAttributesTypeName})
271
287
  fileContent += `/** Frontend model for ${className}. */\n`
272
288
  fileContent += `export default class ${className} extends FrontendModelBase {\n`
289
+ fileContent += " /**\n"
290
+ fileContent += ` * Creates a ${className}.\n`
291
+ fileContent += ` * @param {${createAttributesTypeName}} [attributes] - Attributes for the new model.\n`
292
+ fileContent += ` * @returns {Promise<${className}>} - Persisted model.\n`
293
+ fileContent += " */\n"
294
+ fileContent += ` static async create(attributes = {}) { return /** @type {Promise<${className}>} */ (super.create(attributes)) }\n\n`
295
+
296
+ fileContent += " /**\n"
297
+ fileContent += ` * Updates this ${className}.\n`
298
+ fileContent += ` * @param {${updateAttributesTypeName}} [newAttributes] - Attributes to assign before saving.\n`
299
+ fileContent += ` * @returns {Promise<${className}>} - Updated model.\n`
300
+ fileContent += " */\n"
301
+ fileContent += ` async update(newAttributes = {}) { return /** @type {Promise<${className}>} */ (super.update(newAttributes)) }\n\n`
273
302
  fileContent += " /** @returns {FrontendModelResourceConfig} - Resource config. */\n"
274
303
  fileContent += " static resourceConfig() {\n"
275
304
  fileContent += " return {\n"
@@ -522,6 +551,146 @@ export default class DbGenerateFrontendModels extends BaseCommand {
522
551
  return content
523
552
  }
524
553
 
554
+ /**
555
+ * Runs write attributes typedef.
556
+ * @param {object} args - Arguments.
557
+ * @param {Array<{jsDocType: string, name: string}>} args.attributes - Generated read attributes.
558
+ * @param {string} args.attributesTypeName - Generated read attributes typedef name.
559
+ * @param {Array<{attributes: Array<{name: string, type: string}>, relationshipName: string, typeName: string}>} args.nestedWriteTypes - Nested write typedefs.
560
+ * @param {Array<string | Record<string, ?>>} args.permittedParams - Resource permitted params spec.
561
+ * @param {string} args.typeName - Typedef name.
562
+ * @returns {string} - Generated typedef source.
563
+ */
564
+ writeAttributesTypedef({attributes, attributesTypeName, nestedWriteTypes, permittedParams, typeName}) {
565
+ let output = "/**\n"
566
+
567
+ output += ` * Attributes accepted by ${typeName}.\n`
568
+ output += ` * @typedef {object} ${typeName}\n`
569
+
570
+ const attributesByName = new Map(attributes.map((attribute) => [attribute.name, attribute]))
571
+ const nestedWriteTypesByKey = new Map(nestedWriteTypes.map((nestedWriteType) => [`${nestedWriteType.relationshipName}Attributes`, nestedWriteType]))
572
+
573
+ for (const entry of permittedParams) {
574
+ if (typeof entry == "string") {
575
+ const attribute = attributesByName.get(entry)
576
+ const type = attribute ? `${attributesTypeName}[${JSON.stringify(attribute.name)}]` : "?"
577
+
578
+ output += ` * @property {${type}} [${entry}] - Permitted ${entry} value.\n`
579
+ } else if (entry && typeof entry == "object" && !Array.isArray(entry)) {
580
+ for (const key of Object.keys(entry)) {
581
+ const nestedWriteType = nestedWriteTypesByKey.get(key)
582
+ const type = nestedWriteType ? `Array<${nestedWriteType.typeName}>` : "Array<Record<string, ?>>"
583
+
584
+ output += ` * @property {${type}} [${key}] - Permitted nested ${key} values.\n`
585
+ }
586
+ }
587
+ }
588
+
589
+ output += " */\n"
590
+
591
+ return output
592
+ }
593
+
594
+ /**
595
+ * Runs nested write types for model.
596
+ * @param {object} args - Arguments.
597
+ * @param {string} args.className - Frontend model class name.
598
+ * @param {Array<string | Record<string, ?>>} args.permittedParams - Combined permitted params specs.
599
+ * @param {Array<{autoload: boolean, relationshipName: string, targetClassName: string, targetFileName: string, type: "belongsTo" | "hasOne" | "hasMany"}>} args.relationships - Generated relationships.
600
+ * @returns {Array<{attributes: Array<{name: string, type: string}>, relationshipName: string, typeName: string}>} - Nested write typedefs.
601
+ */
602
+ nestedWriteTypesForModel({className, permittedParams, relationships}) {
603
+ const relationshipsByName = new Map(relationships.map((relationship) => [relationship.relationshipName, relationship]))
604
+ const nestedWriteTypesByName = new Map()
605
+
606
+ for (const entry of permittedParams) {
607
+ if (!entry || typeof entry != "object" || Array.isArray(entry)) continue
608
+
609
+ for (const key of Object.keys(entry)) {
610
+ if (!key.endsWith("Attributes")) continue
611
+ const relationshipName = key.slice(0, -"Attributes".length)
612
+ const nestedSpec = entry[key]
613
+ const relationship = relationshipsByName.get(relationshipName)
614
+ let targetModelClass
615
+
616
+ if (relationship) {
617
+ try {
618
+ targetModelClass = this.getConfiguration().getModelClass(relationship.targetClassName)
619
+ } catch {
620
+ targetModelClass = undefined
621
+ }
622
+ }
623
+
624
+ if (nestedWriteTypesByName.has(relationshipName)) continue
625
+
626
+ nestedWriteTypesByName.set(relationshipName, {
627
+ attributes: this.nestedWriteAttributesForSpec({nestedSpec, targetModelClass}),
628
+ relationshipName,
629
+ typeName: `${className}${inflection.camelize(relationshipName)}NestedAttributes`
630
+ })
631
+ }
632
+ }
633
+
634
+ return Array.from(nestedWriteTypesByName.values())
635
+ }
636
+
637
+ /**
638
+ * Runs nested write attributes for spec.
639
+ * @param {object} args - Arguments.
640
+ * @param {?} args.nestedSpec - Nested permit spec.
641
+ * @param {typeof import("../../../../../database/record/index.js").default | undefined} args.targetModelClass - Target backend model class.
642
+ * @returns {Array<{name: string, type: string}>} - Nested write attributes.
643
+ */
644
+ nestedWriteAttributesForSpec({nestedSpec, targetModelClass}) {
645
+ if (!Array.isArray(nestedSpec)) return []
646
+
647
+ return nestedSpec.filter((entry) => typeof entry == "string").map((attributeName) => {
648
+ const attributeConfig = this.frontendAttributeConfigForModelAttribute({attributeName, modelClass: targetModelClass})
649
+
650
+ return {
651
+ name: attributeName,
652
+ type: attributeConfig ? this.jsDocTypeForFrontendAttribute({attributeConfig}) : "?"
653
+ }
654
+ })
655
+ }
656
+
657
+ /**
658
+ * Runs permitted params for generator.
659
+ * @param {import("../../../../../configuration-types.js").FrontendModelResourceClassType | null} resourceClass - Resource class.
660
+ * @param {"create" | "update"} action - Write action.
661
+ * @returns {Array<string | Record<string, ?>>} - Permitted params spec.
662
+ */
663
+ permittedParamsForGenerator(resourceClass, action) {
664
+ if (!resourceClass || typeof resourceClass !== "function") return []
665
+
666
+ const prototypeWithMethod = /**
667
+ * Resource prototype.
668
+ * @type {{permittedParams?: (arg?: object) => Array<string | Record<string, ?>>}}
669
+ */ (resourceClass.prototype)
670
+
671
+ if (typeof prototypeWithMethod?.permittedParams !== "function") return []
672
+
673
+ try {
674
+ const instance = new resourceClass({
675
+ ability: undefined,
676
+ context: {},
677
+ locals: {},
678
+ modelClass: resourceClass.ModelClass,
679
+ modelName: resourceClass.ModelClass?.getModelName?.() || resourceClass.name,
680
+ params: {},
681
+ resourceConfiguration: /**
682
+ * Resource configuration.
683
+ * @type {import("../../../../../configuration-types.js").FrontendModelResourceConfiguration}
684
+ */ ({attributes: []})
685
+ })
686
+ const spec = instance.permittedParams({action, ability: undefined, locals: {}, params: {}})
687
+
688
+ return Array.isArray(spec) ? spec : []
689
+ } catch (error) {
690
+ throw new Error(`Failed to invoke ${resourceClass.name}.permittedParams() while generating frontend model write types: ${error instanceof Error ? error.message : String(error)}`, {cause: error})
691
+ }
692
+ }
693
+
525
694
  /**
526
695
  * Invokes a backend resource's `permittedParams()` instance method at
527
696
  * generation time and extracts the relationship names that accept
@@ -465,22 +465,45 @@ function frontendModelAttachmentParams(params) {
465
465
  /**
466
466
  * Extract mutation attributes shared by create and update commands.
467
467
  * @param {Record<string, ?>} params - Frontend-model request params.
468
- * @returns {{attributes: Record<string, ?>, nestedAttributes: Record<string, ?> | null} | string} - Mutation attributes or validation error message.
468
+ * @returns {{attributes: Record<string, ?>, attachments: Record<string, ?> | null, nestedAttributes: Record<string, ?> | null} | string} - Mutation attributes or validation error message.
469
469
  */
470
470
  function frontendModelMutationAttributes(params) {
471
471
  const attributes = params.attributes
472
472
 
473
- if (!attributes || typeof attributes !== "object") {
473
+ if (!isPlainObject(attributes)) {
474
474
  return "Expected model attributes."
475
475
  }
476
476
 
477
+ /** @type {Record<string, ?>} */
478
+ const regularAttributes = {}
479
+ /** @type {Record<string, ?>} */
480
+ const nestedAttributes = {}
481
+
482
+ for (const [attributeName, value] of Object.entries(attributes)) {
483
+ if (attributeName.endsWith("Attributes")) {
484
+ const relationshipName = attributeName.slice(0, -"Attributes".length)
485
+
486
+ if (!relationshipName) return `Invalid nested attributes key: ${attributeName}`
487
+ nestedAttributes[relationshipName] = value
488
+ } else {
489
+ regularAttributes[attributeName] = value
490
+ }
491
+ }
492
+
493
+ if (params.nestedAttributes !== undefined) {
494
+ if (!isPlainObject(params.nestedAttributes)) return "Expected nestedAttributes to be an object."
495
+
496
+ Object.assign(nestedAttributes, params.nestedAttributes)
497
+ }
498
+
499
+ if (params.attachments !== undefined && !isPlainObject(params.attachments)) {
500
+ return "Expected attachments to be an object."
501
+ }
502
+
477
503
  return {
478
- attributes,
479
- nestedAttributes: params.nestedAttributes && typeof params.nestedAttributes === "object"
480
- ? /**
481
- * Types the following value.
482
- @type {Record<string, ?>} */ (params.nestedAttributes)
483
- : null
504
+ attributes: regularAttributes,
505
+ attachments: params.attachments === undefined ? null : params.attachments,
506
+ nestedAttributes: Object.keys(nestedAttributes).length > 0 ? nestedAttributes : null
484
507
  }
485
508
  }
486
509
 
@@ -1036,11 +1059,12 @@ export default class FrontendModelController extends Controller {
1036
1059
  * Runs frontend model create record.
1037
1060
  * @param {Record<string, ?>} attributes - Create attributes.
1038
1061
  * @param {Record<string, ?> | null} [nestedAttributes] - Optional nested-attribute payload for cascading writes.
1062
+ * @param {Record<string, ?> | null} [attachments] - Optional attachment payloads keyed by attachment name.
1039
1063
  * @returns {Promise<import("./database/record/index.js").default | null>} - Created model when authorized.
1040
1064
  */
1041
- async frontendModelCreateRecord(attributes, nestedAttributes = null) {
1065
+ async frontendModelCreateRecord(attributes, nestedAttributes = null, attachments = null) {
1042
1066
  const resource = this.frontendModelResourceInstance()
1043
- const model = await resource.create(attributes, {nestedAttributes, controller: this})
1067
+ const model = await resource.create(attributes, {attachments, nestedAttributes, controller: this})
1044
1068
 
1045
1069
  const authorizedModels = await this.frontendModelFilterAuthorizedModels({action: "create", models: [model]})
1046
1070
 
@@ -2943,7 +2967,11 @@ export default class FrontendModelController extends Controller {
2943
2967
  const mutationAttributes = frontendModelMutationAttributes(params)
2944
2968
  if (typeof mutationAttributes === "string") return this.frontendModelErrorPayload(mutationAttributes)
2945
2969
 
2946
- const model = await this.frontendModelCreateRecord(mutationAttributes.attributes, mutationAttributes.nestedAttributes)
2970
+ const model = await this.frontendModelCreateRecord(
2971
+ mutationAttributes.attributes,
2972
+ mutationAttributes.nestedAttributes,
2973
+ mutationAttributes.attachments
2974
+ )
2947
2975
 
2948
2976
  if (!model) {
2949
2977
  return this.frontendModelErrorPayload(`${modelClass.name} not found.`)
@@ -3056,7 +3084,11 @@ export default class FrontendModelController extends Controller {
3056
3084
  return this.frontendModelErrorPayload(`${modelClass.name} not found.`)
3057
3085
  }
3058
3086
 
3059
- const updatedModel = await resource.update(model, mutationAttributes.attributes, {nestedAttributes: mutationAttributes.nestedAttributes, controller: this})
3087
+ const updatedModel = await resource.update(model, mutationAttributes.attributes, {
3088
+ attachments: mutationAttributes.attachments,
3089
+ controller: this,
3090
+ nestedAttributes: mutationAttributes.nestedAttributes
3091
+ })
3060
3092
  const serializedModel = await resource.serialize(updatedModel, "update")
3061
3093
 
3062
3094
  return frontendModelSerializedModelSuccess(serializedModel)