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
@@ -24,10 +24,22 @@ import {readPayloadAssociationCount, readPayloadComputedAbility, readPayloadQuer
24
24
  * Defines this typedef.
25
25
  * @typedef {{type: "hasOne" | "hasMany"}} FrontendModelAttachmentDefinition
26
26
  */
27
+ /**
28
+ * Attachment input accepted by frontend-model attachment helpers before normalization.
29
+ * @typedef {Record<string, ?> | {arrayBuffer: () => Promise<ArrayBuffer>, type?: string, name?: string} | null | undefined} FrontendModelAttachmentInput
30
+ */
27
31
  /**
28
32
  * Defines this typedef.
29
33
  * @typedef {{attributes?: string[], builtInCollectionCommands?: string[], builtInMemberCommands?: string[], collectionCommands?: string[], commands?: string[], memberCommands?: string[], attachments?: Record<string, FrontendModelAttachmentDefinition>, modelName?: string, nestedAttributes?: Record<string, {allowDestroy?: boolean, limit?: number}>, primaryKey?: string, relationships?: string[]}} FrontendModelResourceConfig
30
34
  */
35
+ /**
36
+ * Frontend model constructor type.
37
+ * @typedef {{new (attributes?: Record<string, ?>): FrontendModelBase}} FrontendModelConstructor
38
+ */
39
+ /**
40
+ * Frontend model static side without generated per-model create overloads.
41
+ * @typedef {FrontendModelConstructor & Omit<typeof FrontendModelBase, "create">} FrontendModelClass
42
+ */
31
43
  /**
32
44
  * FrontendModelTransportConfig type.
33
45
  * @typedef {object} FrontendModelTransportConfig
@@ -57,7 +69,7 @@ const QUERY_DATA_KEY = "__queryData"
57
69
  const ABILITIES_KEY = "__abilities"
58
70
  /**
59
71
  * Pending shared frontend model requests.
60
- @type {Array<{commandName?: string, commandType: FrontendModelRequestCommandType, customPath?: string, modelClass: typeof FrontendModelBase, payload: Record<string, ?>, requestId: string, resolve: (response: Record<string, ?>) => void, reject: (error: ?) => void, resourcePath?: string | null}>} */
72
+ @type {Array<{commandName?: string, commandType: FrontendModelRequestCommandType, customPath?: string, modelClass: FrontendModelClass, payload: Record<string, ?>, requestId: string, resolve: (response: Record<string, ?>) => void, reject: (error: ?) => void, resourcePath?: string | null}>} */
61
73
  let pendingSharedFrontendModelRequests = []
62
74
  let sharedFrontendModelRequestId = 0
63
75
  let sharedFrontendModelFlushScheduled = false
@@ -204,7 +216,7 @@ async function flushBufferedOutgoingEventsAfterReconnect() {
204
216
 
205
217
  /**
206
218
  * Runs default frontend model resource path.
207
- * @param {typeof FrontendModelBase} modelClass - Frontend model class.
219
+ * @param {FrontendModelClass} modelClass - Frontend model class.
208
220
  * @returns {string} - Default resource path for the model class.
209
221
  */
210
222
  function defaultFrontendModelResourcePath(modelClass) {
@@ -226,8 +238,8 @@ export class AttributeNotSelectedError extends Error {
226
238
 
227
239
  /**
228
240
  * Lightweight singular relationship state holder for frontend model instances.
229
- * @template {typeof FrontendModelBase} S
230
- * @template {typeof FrontendModelBase} T
241
+ * @template {FrontendModelClass} S
242
+ * @template {FrontendModelClass} T
231
243
  */
232
244
  export class FrontendModelSingularRelationship {
233
245
  /**
@@ -296,8 +308,8 @@ export class FrontendModelSingularRelationship {
296
308
 
297
309
  /**
298
310
  * Lightweight has-many relationship state holder for frontend model instances.
299
- * @template {typeof FrontendModelBase} S
300
- * @template {typeof FrontendModelBase} T
311
+ * @template {FrontendModelClass} S
312
+ * @template {FrontendModelClass} T
301
313
  */
302
314
  export class FrontendModelHasManyRelationship {
303
315
  /**
@@ -614,6 +626,17 @@ function frontendModelPayloadContainsAttachmentUpload(value) {
614
626
  return Object.values(value).some((entry) => frontendModelPayloadContainsAttachmentUpload(entry))
615
627
  }
616
628
 
629
+ /**
630
+ * Returns the concrete frontend-model class for an instance.
631
+ * @param {FrontendModelBase} model - Frontend model instance.
632
+ * @returns {FrontendModelClass} Concrete frontend-model class.
633
+ */
634
+ function frontendModelClassFor(model) {
635
+ const constructorValue = model.constructor
636
+
637
+ return /** @type {FrontendModelClass} */ (constructorValue)
638
+ }
639
+
617
640
  /**
618
641
  * Runs normalize frontend attachment input.
619
642
  * @param {?} input - Attachment input.
@@ -691,6 +714,12 @@ async function normalizeFrontendAttachmentInput(input) {
691
714
  * Frontend-model attachment helper for one attachment name.
692
715
  */
693
716
  export class FrontendModelAttachmentHandle {
717
+ /**
718
+ * Pending attachment inputs queued for the next model save.
719
+ * @type {FrontendModelAttachmentInput[]}
720
+ */
721
+ pendingInputs = []
722
+
694
723
  /**
695
724
  * Runs constructor.
696
725
  * @param {object} args - Options.
@@ -702,15 +731,70 @@ export class FrontendModelAttachmentHandle {
702
731
  this.attachmentName = attachmentName
703
732
  }
704
733
 
734
+ /**
735
+ * Queue attachment input for the parent model's next save.
736
+ * @param {FrontendModelAttachmentInput | FrontendModelAttachmentInput[]} input - Attachment input.
737
+ * @returns {void}
738
+ */
739
+ queueAttach(input) {
740
+ const ModelClass = frontendModelClassFor(this.model)
741
+ const attachmentDefinition = ModelClass.attachmentDefinition(this.attachmentName)
742
+
743
+ if (attachmentDefinition?.type === "hasOne") {
744
+ if (Array.isArray(input)) {
745
+ const lastInput = input[input.length - 1]
746
+
747
+ this.pendingInputs = typeof lastInput === "undefined" ? [] : [lastInput]
748
+ } else {
749
+ this.pendingInputs = [input]
750
+ }
751
+ return
752
+ }
753
+
754
+ if (Array.isArray(input)) {
755
+ this.pendingInputs.push(...input)
756
+ } else {
757
+ this.pendingInputs.push(input)
758
+ }
759
+ }
760
+
761
+ /**
762
+ * Whether this attachment has queued inputs for the next model save.
763
+ * @returns {boolean} Whether any pending inputs exist.
764
+ */
765
+ hasPendingAttachments() {
766
+ return this.pendingInputs.length > 0
767
+ }
768
+
769
+ /**
770
+ * Builds the save payload for queued attachment inputs.
771
+ * @returns {Promise<Record<string, ?> | Record<string, ?>[] | undefined>} Normalized attachment payload.
772
+ */
773
+ async pendingAttachmentsPayload() {
774
+ if (this.pendingInputs.length === 0) return undefined
775
+
776
+ const ModelClass = frontendModelClassFor(this.model)
777
+ const attachmentDefinition = ModelClass.attachmentDefinition(this.attachmentName)
778
+
779
+ if (attachmentDefinition?.type === "hasMany") {
780
+ return await Promise.all(this.pendingInputs.map(async (input) => await normalizeFrontendAttachmentInput(input)))
781
+ }
782
+
783
+ return await normalizeFrontendAttachmentInput(this.pendingInputs[this.pendingInputs.length - 1])
784
+ }
785
+
786
+ /** Clears queued attachment inputs after a successful model save. */
787
+ clearPendingAttachments() {
788
+ this.pendingInputs = []
789
+ }
790
+
705
791
  /**
706
792
  * Runs attach.
707
793
  * @param {?} input - Attachment input.
708
794
  * @returns {Promise<void>} - Resolves when attached.
709
795
  */
710
796
  async attach(input) {
711
- const ModelClass = /**
712
- * Narrows the runtime value to the documented type.
713
- @type {typeof FrontendModelBase} */ (this.model.constructor)
797
+ const ModelClass = frontendModelClassFor(this.model)
714
798
  const normalizedInput = await normalizeFrontendAttachmentInput(input)
715
799
  const response = await ModelClass.executeCommand("attach", {
716
800
  attachment: normalizedInput,
@@ -727,9 +811,7 @@ export class FrontendModelAttachmentHandle {
727
811
  * @returns {Promise<FrontendModelAttachmentDownload | null>} - Downloaded attachment payload.
728
812
  */
729
813
  async download(attachmentId) {
730
- const ModelClass = /**
731
- * Narrows the runtime value to the documented type.
732
- @type {typeof FrontendModelBase} */ (this.model.constructor)
814
+ const ModelClass = frontendModelClassFor(this.model)
733
815
  const response = await ModelClass.executeCommand("download", frontendModelAttachmentCommandPayload(this, attachmentId))
734
816
  const attachmentPayload = response.attachment
735
817
 
@@ -755,9 +837,7 @@ export class FrontendModelAttachmentHandle {
755
837
  * @returns {Promise<string | null>} - Resolvable attachment URL.
756
838
  */
757
839
  async url(attachmentId) {
758
- const ModelClass = /**
759
- * Narrows the runtime value to the documented type.
760
- @type {typeof FrontendModelBase} */ (this.model.constructor)
840
+ const ModelClass = frontendModelClassFor(this.model)
761
841
  const response = await ModelClass.executeCommand("url", frontendModelAttachmentCommandPayload(this, attachmentId))
762
842
 
763
843
  if (typeof response.url === "string" && response.url.length > 0) {
@@ -772,9 +852,7 @@ export class FrontendModelAttachmentHandle {
772
852
  * @returns {string} - Download URL for this attachment on the configured backend.
773
853
  */
774
854
  downloadUrl() {
775
- const ModelClass = /**
776
- * Narrows the runtime value to the documented type.
777
- @type {typeof FrontendModelBase} */ (this.model.constructor)
855
+ const ModelClass = frontendModelClassFor(this.model)
778
856
  const commandName = ModelClass.commandName("download")
779
857
  const resourcePath = ModelClass.resourcePath()
780
858
  const commandUrl = frontendModelCommandUrl(resourcePath, commandName)
@@ -831,7 +909,7 @@ const FRONTEND_MODELS_CHANNEL_NAME = "frontend-models"
831
909
 
832
910
  /**
833
911
  * Defines this typedef.
834
- * @typedef {{callback: (payload: {id: string, model: InstanceType<typeof FrontendModelBase>}) => void, eventFilterKey: string | null, eventFilterPayload: import("./query.js").FrontendModelEventFilterPayload | null, projectionPayload: import("./query.js").FrontendModelProjectionPayload}} FrontendModelModelEventCallbackEntry
912
+ * @typedef {{callback: (payload: {id: string, model: FrontendModelBase}) => void, eventFilterKey: string | null, eventFilterPayload: import("./query.js").FrontendModelEventFilterPayload | null, projectionPayload: import("./query.js").FrontendModelProjectionPayload}} FrontendModelModelEventCallbackEntry
835
913
  */
836
914
  /**
837
915
  * Defines this typedef.
@@ -981,7 +1059,7 @@ function frontendModelEventEntryMatches(entry, matchedEventFilterKeys) {
981
1059
 
982
1060
  /**
983
1061
  * Runs assert no destroy event filter.
984
- * @param {typeof FrontendModelBase} ModelClass - Event model class.
1062
+ * @param {FrontendModelClass} ModelClass - Event model class.
985
1063
  * @param {import("./query.js").FrontendModelEventOptions} options - Event options.
986
1064
  * @returns {void}
987
1065
  */
@@ -1007,7 +1085,7 @@ function assertNoDestroyEventFilter(ModelClass, options) {
1007
1085
  class FrontendModelEventSubscription {
1008
1086
  /**
1009
1087
  * Runs constructor.
1010
- * @param {typeof FrontendModelBase} ModelClass - Frontend model class for this subscription bucket.
1088
+ * @param {FrontendModelClass} ModelClass - Frontend model class for this subscription bucket.
1011
1089
  */
1012
1090
  constructor(ModelClass) {
1013
1091
  this.ModelClass = ModelClass
@@ -1025,7 +1103,7 @@ class FrontendModelEventSubscription {
1025
1103
  this.classDestroyCallbacks = new Set()
1026
1104
  /**
1027
1105
  * Narrows the runtime value to the documented type.
1028
- @type {Map<string, {instance: InstanceType<typeof FrontendModelBase>, updateCallbacks: Set<FrontendModelModelEventCallbackEntry>, destroyCallbacks: Set<FrontendModelDestroyEventCallbackEntry>}>} */
1106
+ @type {Map<string, {instance: FrontendModelBase, updateCallbacks: Set<FrontendModelModelEventCallbackEntry>, destroyCallbacks: Set<FrontendModelDestroyEventCallbackEntry>}>} */
1029
1107
  this.instanceListeners = new Map()
1030
1108
  /**
1031
1109
  * Narrows the runtime value to the documented type.
@@ -1263,12 +1341,12 @@ class FrontendModelEventSubscription {
1263
1341
 
1264
1342
  /**
1265
1343
  * Frontend model event subscriptions.
1266
- @type {WeakMap<typeof FrontendModelBase, FrontendModelEventSubscription>} */
1344
+ @type {WeakMap<FrontendModelClass, FrontendModelEventSubscription>} */
1267
1345
  const frontendModelEventSubscriptions = new WeakMap()
1268
1346
 
1269
1347
  /**
1270
1348
  * Runs ensure frontend model event subscription.
1271
- * @param {typeof FrontendModelBase} ModelClass - Model class.
1349
+ * @param {FrontendModelClass} ModelClass - Model class.
1272
1350
  * @returns {FrontendModelEventSubscription} - Per-class subscription helper.
1273
1351
  */
1274
1352
  function ensureFrontendModelEventSubscription(ModelClass) {
@@ -1286,8 +1364,8 @@ function ensureFrontendModelEventSubscription(ModelClass) {
1286
1364
  * Runs ensure frontend model instance listener.
1287
1365
  * @param {FrontendModelEventSubscription} sub - Event subscription bucket.
1288
1366
  * @param {string} id - Model id.
1289
- * @param {InstanceType<typeof FrontendModelBase>} instance - Listener instance.
1290
- * @returns {{instance: InstanceType<typeof FrontendModelBase>, updateCallbacks: Set<FrontendModelModelEventCallbackEntry>, destroyCallbacks: Set<FrontendModelDestroyEventCallbackEntry>}} - Instance listener bucket.
1367
+ * @param {FrontendModelBase} instance - Listener instance.
1368
+ * @returns {{instance: FrontendModelBase, updateCallbacks: Set<FrontendModelModelEventCallbackEntry>, destroyCallbacks: Set<FrontendModelDestroyEventCallbackEntry>}} - Instance listener bucket.
1291
1369
  */
1292
1370
  function ensureFrontendModelInstanceListener(sub, id, instance) {
1293
1371
  let listener = sub.instanceListeners.get(id)
@@ -1648,12 +1726,17 @@ export default class FrontendModelBase {
1648
1726
  _attributes
1649
1727
  /**
1650
1728
  * Narrows the runtime value to the documented type.
1651
- @type {Record<string, FrontendModelHasManyRelationship<typeof FrontendModelBase, typeof FrontendModelBase> | FrontendModelSingularRelationship<typeof FrontendModelBase, typeof FrontendModelBase>>} */
1729
+ @type {Record<string, FrontendModelHasManyRelationship<FrontendModelClass, FrontendModelClass> | FrontendModelSingularRelationship<FrontendModelClass, FrontendModelClass>>} */
1652
1730
  _relationships
1653
1731
  /**
1654
1732
  * Narrows the runtime value to the documented type.
1655
1733
  @type {Record<string, FrontendModelAttachmentHandle>} */
1656
1734
  _attachments
1735
+ /**
1736
+ * Rails-style nested attribute payloads queued for the next save.
1737
+ * @type {Record<string, ?>}
1738
+ */
1739
+ _pendingNestedAttributes
1657
1740
  /**
1658
1741
  * Narrows the runtime value to the documented type.
1659
1742
  @type {Set<string> | null} */
@@ -1681,14 +1764,13 @@ export default class FrontendModelBase {
1681
1764
  * @param {Record<string, ?>} [attributes] - Initial attributes.
1682
1765
  */
1683
1766
  constructor(attributes = {}) {
1684
- const ModelClass = /**
1685
- * Narrows the runtime value to the documented type.
1686
- @type {typeof FrontendModelBase} */ (this.constructor)
1767
+ const ModelClass = frontendModelClassFor(this)
1687
1768
 
1688
1769
  ModelClass.ensureGeneratedAttachmentMethods()
1689
1770
  this._attributes = {}
1690
1771
  this._relationships = {}
1691
1772
  this._attachments = {}
1773
+ this._pendingNestedAttributes = {}
1692
1774
  this._selectedAttributes = null
1693
1775
  this._isNewRecord = true
1694
1776
  this._markedForDestruction = false
@@ -1698,7 +1780,7 @@ export default class FrontendModelBase {
1698
1780
 
1699
1781
  /**
1700
1782
  * Runs ensure generated attachment methods.
1701
- * @this {typeof FrontendModelBase}
1783
+ * @this {FrontendModelClass}
1702
1784
  * @returns {void} - Ensures attachment helper methods exist on the prototype.
1703
1785
  */
1704
1786
  static ensureGeneratedAttachmentMethods() {
@@ -1732,8 +1814,8 @@ export default class FrontendModelBase {
1732
1814
 
1733
1815
  /**
1734
1816
  * Runs relationship model classes.
1735
- * @this {typeof FrontendModelBase}
1736
- * @returns {Record<string, typeof FrontendModelBase | string>} - Relationship model classes (or class name strings) keyed by relationship name.
1817
+ * @this {FrontendModelClass}
1818
+ * @returns {Record<string, FrontendModelClass | string>} - Relationship model classes (or class name strings) keyed by relationship name.
1737
1819
  */
1738
1820
  static relationshipModelClasses() {
1739
1821
  return {}
@@ -1741,7 +1823,7 @@ export default class FrontendModelBase {
1741
1823
 
1742
1824
  /**
1743
1825
  * Register a frontend model class so it can be resolved by name in relationship lookups.
1744
- * @param {typeof FrontendModelBase} modelClass - Model class to register.
1826
+ * @param {FrontendModelClass} modelClass - Model class to register.
1745
1827
  * @returns {void}
1746
1828
  */
1747
1829
  static registerModel(modelClass) {
@@ -1751,7 +1833,7 @@ export default class FrontendModelBase {
1751
1833
  /**
1752
1834
  * Runs define scope.
1753
1835
  * @param {(...args: Array<?>) => ?} callback - Scope callback.
1754
- * @returns {((...args: Array<?>) => import("./query.js").default<typeof FrontendModelBase>) & {scope: (...args: Array<?>) => import("../utils/model-scope.js").ModelScopeDescriptor}} - Scope helper.
1836
+ * @returns {((...args: Array<?>) => import("./query.js").default<FrontendModelClass>) & {scope: (...args: Array<?>) => import("../utils/model-scope.js").ModelScopeDescriptor}} - Scope helper.
1755
1837
  */
1756
1838
  static defineScope(callback) {
1757
1839
  return defineModelScope({
@@ -1763,8 +1845,8 @@ export default class FrontendModelBase {
1763
1845
 
1764
1846
  /**
1765
1847
  * Resolve a relationship model class value that may be a class reference or a string name.
1766
- * @param {typeof FrontendModelBase | string | null | undefined} value - Class or class name.
1767
- * @returns {typeof FrontendModelBase | null} - Resolved model class.
1848
+ * @param {FrontendModelClass | string | null | undefined} value - Class or class name.
1849
+ * @returns {FrontendModelClass | null} - Resolved model class.
1768
1850
  */
1769
1851
  static resolveModelClass(value) {
1770
1852
  return resolveFrontendModelClass(value)
@@ -1772,7 +1854,7 @@ export default class FrontendModelBase {
1772
1854
 
1773
1855
  /**
1774
1856
  * Runs relationship definitions.
1775
- * @this {typeof FrontendModelBase}
1857
+ * @this {FrontendModelClass}
1776
1858
  * @returns {Record<string, {type: "belongsTo" | "hasOne" | "hasMany", autoload?: boolean}>} - Relationship definitions keyed by relationship name.
1777
1859
  */
1778
1860
  static relationshipDefinitions() {
@@ -1781,7 +1863,7 @@ export default class FrontendModelBase {
1781
1863
 
1782
1864
  /**
1783
1865
  * Runs attachment definitions.
1784
- * @this {typeof FrontendModelBase}
1866
+ * @this {FrontendModelClass}
1785
1867
  * @returns {Record<string, FrontendModelAttachmentDefinition>} - Attachment definitions keyed by attachment name.
1786
1868
  */
1787
1869
  static attachmentDefinitions() {
@@ -1790,7 +1872,7 @@ export default class FrontendModelBase {
1790
1872
 
1791
1873
  /**
1792
1874
  * Runs attachment definition.
1793
- * @this {typeof FrontendModelBase}
1875
+ * @this {FrontendModelClass}
1794
1876
  * @param {string} attachmentName - Attachment name.
1795
1877
  * @returns {FrontendModelAttachmentDefinition | null} - Attachment definition.
1796
1878
  */
@@ -1800,7 +1882,7 @@ export default class FrontendModelBase {
1800
1882
 
1801
1883
  /**
1802
1884
  * Runs relationship definition.
1803
- * @this {typeof FrontendModelBase}
1885
+ * @this {FrontendModelClass}
1804
1886
  * @param {string} relationshipName - Relationship name.
1805
1887
  * @returns {{type: "belongsTo" | "hasOne" | "hasMany", autoload?: boolean} | null} - Relationship definition.
1806
1888
  */
@@ -1810,11 +1892,28 @@ export default class FrontendModelBase {
1810
1892
  return definitions[relationshipName] || null
1811
1893
  }
1812
1894
 
1895
+ /**
1896
+ * Resolves a Rails-style nested attributes key to a configured relationship.
1897
+ * @this {FrontendModelClass}
1898
+ * @param {string} attributeName - Candidate attribute name, such as `tasksAttributes`.
1899
+ * @returns {string | null} Relationship name when nested attributes are configured.
1900
+ */
1901
+ static nestedAttributesRelationshipName(attributeName) {
1902
+ if (!attributeName.endsWith("Attributes")) return null
1903
+
1904
+ const relationshipName = attributeName.slice(0, -"Attributes".length)
1905
+ const nestedAttributesConfig = this.resourceConfig().nestedAttributes || {}
1906
+
1907
+ return Object.prototype.hasOwnProperty.call(nestedAttributesConfig, relationshipName)
1908
+ ? relationshipName
1909
+ : null
1910
+ }
1911
+
1813
1912
  /**
1814
1913
  * Runs relationship model class.
1815
- * @this {typeof FrontendModelBase}
1914
+ * @this {FrontendModelClass}
1816
1915
  * @param {string} relationshipName - Relationship name.
1817
- * @returns {typeof FrontendModelBase | null} - Target relationship model class.
1916
+ * @returns {FrontendModelClass | null} - Target relationship model class.
1818
1917
  */
1819
1918
  static relationshipModelClass(relationshipName) {
1820
1919
  const relationshipModelClasses = this.relationshipModelClasses()
@@ -1911,13 +2010,11 @@ export default class FrontendModelBase {
1911
2010
  /**
1912
2011
  * Runs get relationship by name.
1913
2012
  * @param {string} relationshipName - Relationship name.
1914
- * @returns {FrontendModelHasManyRelationship<typeof FrontendModelBase, typeof FrontendModelBase> | FrontendModelSingularRelationship<typeof FrontendModelBase, typeof FrontendModelBase>} - Relationship state object.
2013
+ * @returns {FrontendModelHasManyRelationship<FrontendModelClass, FrontendModelClass> | FrontendModelSingularRelationship<FrontendModelClass, FrontendModelClass>} - Relationship state object.
1915
2014
  */
1916
2015
  getRelationshipByName(relationshipName) {
1917
2016
  if (!this._relationships[relationshipName]) {
1918
- const ModelClass = /**
1919
- * Narrows the runtime value to the documented type.
1920
- @type {typeof FrontendModelBase} */ (this.constructor)
2017
+ const ModelClass = frontendModelClassFor(this)
1921
2018
  const relationshipDefinition = ModelClass.relationshipDefinition(relationshipName)
1922
2019
  const targetModelClass = ModelClass.relationshipModelClass(relationshipName)
1923
2020
 
@@ -1937,9 +2034,7 @@ export default class FrontendModelBase {
1937
2034
  * @returns {FrontendModelAttachmentHandle} - Attachment helper.
1938
2035
  */
1939
2036
  getAttachmentByName(attachmentName) {
1940
- const ModelClass = /**
1941
- * Narrows the runtime value to the documented type.
1942
- @type {typeof FrontendModelBase} */ (this.constructor)
2037
+ const ModelClass = frontendModelClassFor(this)
1943
2038
  const attachmentDefinition = ModelClass.attachmentDefinition(attachmentName)
1944
2039
 
1945
2040
  if (!attachmentDefinition) {
@@ -1962,9 +2057,7 @@ export default class FrontendModelBase {
1962
2057
  * @returns {Promise<?>} - Loaded relationship value.
1963
2058
  */
1964
2059
  async loadRelationship(relationshipName) {
1965
- const ModelClass = /**
1966
- * Narrows the runtime value to the documented type.
1967
- @type {typeof FrontendModelBase} */ (this.constructor)
2060
+ const ModelClass = frontendModelClassFor(this)
1968
2061
  const id = this.primaryKeyValue()
1969
2062
  const reloadedModel = await ModelClass
1970
2063
  .preload([relationshipName])
@@ -1983,7 +2076,7 @@ export default class FrontendModelBase {
1983
2076
  * required columns present are left untouched unless `force` is set. Carries
1984
2077
  * the query's preload graph, select, selectsExtra, withCount, abilities, and
1985
2078
  * queryData when re-fetching.
1986
- * @param {import("./query.js").default<typeof FrontendModelBase> | import("../database/query/index.js").NestedPreloadRecord | string | Array<string | import("../database/query/index.js").NestedPreloadRecord>} queryOrSpec - Preload source.
2079
+ * @param {import("./query.js").default<FrontendModelClass> | import("../database/query/index.js").NestedPreloadRecord | string | Array<string | import("../database/query/index.js").NestedPreloadRecord>} queryOrSpec - Preload source.
1987
2080
  * @param {{force?: boolean}} [options] - Options.
1988
2081
  * @returns {Promise<void>} - Resolves when preloading completes.
1989
2082
  */
@@ -2024,9 +2117,7 @@ export default class FrontendModelBase {
2024
2117
  async _tryCohortPreload(relationshipName) {
2025
2118
  if (!FrontendModelBase.getAutoload()) return false
2026
2119
 
2027
- const ModelClass = /**
2028
- * Narrows the runtime value to the documented type.
2029
- @type {typeof FrontendModelBase} */ (this.constructor)
2120
+ const ModelClass = frontendModelClassFor(this)
2030
2121
  const cohort = this._loadCohort
2031
2122
 
2032
2123
  if (!cohort || cohort.length <= 1) return false
@@ -2101,9 +2192,7 @@ export default class FrontendModelBase {
2101
2192
  * @returns {?} - Assigned relationship value.
2102
2193
  */
2103
2194
  setRelationship(relationshipName, relationshipValue) {
2104
- const ModelClass = /**
2105
- * Narrows the runtime value to the documented type.
2106
- @type {typeof FrontendModelBase} */ (this.constructor)
2195
+ const ModelClass = frontendModelClassFor(this)
2107
2196
  const relationshipDefinition = ModelClass.relationshipDefinition(relationshipName)
2108
2197
 
2109
2198
  if (!relationshipDefinition) {
@@ -2140,7 +2229,7 @@ export default class FrontendModelBase {
2140
2229
 
2141
2230
  /**
2142
2231
  * Runs primary key.
2143
- * @this {typeof FrontendModelBase}
2232
+ * @this {FrontendModelClass}
2144
2233
  * @returns {string} - Primary key name.
2145
2234
  */
2146
2235
  static primaryKey() {
@@ -2152,9 +2241,7 @@ export default class FrontendModelBase {
2152
2241
  * @returns {number | string} - Primary key value.
2153
2242
  */
2154
2243
  primaryKeyValue() {
2155
- const ModelClass = /**
2156
- * Narrows the runtime value to the documented type.
2157
- @type {typeof FrontendModelBase} */ (this.constructor)
2244
+ const ModelClass = frontendModelClassFor(this)
2158
2245
  const value = this.readAttribute(ModelClass.primaryKey())
2159
2246
 
2160
2247
  if (value === undefined || value === null) {
@@ -2296,6 +2383,19 @@ export default class FrontendModelBase {
2296
2383
  * @returns {?} - Assigned value.
2297
2384
  */
2298
2385
  setAttribute(attributeName, newValue) {
2386
+ const ModelClass = frontendModelClassFor(this)
2387
+ const nestedAttributesRelationshipName = ModelClass.nestedAttributesRelationshipName(attributeName)
2388
+
2389
+ if (nestedAttributesRelationshipName) {
2390
+ this._pendingNestedAttributes[nestedAttributesRelationshipName] = newValue
2391
+ return newValue
2392
+ }
2393
+
2394
+ if (ModelClass.attachmentDefinition(attributeName)) {
2395
+ this.getAttachmentByName(attributeName).queueAttach(newValue)
2396
+ return newValue
2397
+ }
2398
+
2299
2399
  const previousValue = this._attributes[attributeName]
2300
2400
 
2301
2401
  this._attributes[attributeName] = newValue
@@ -2330,9 +2430,7 @@ export default class FrontendModelBase {
2330
2430
  _invalidateRelationshipsForAttribute(attributeName) {
2331
2431
  if (!this._relationships || Object.keys(this._relationships).length === 0) return
2332
2432
 
2333
- const ModelClass = /**
2334
- * Narrows the runtime value to the documented type.
2335
- @type {typeof FrontendModelBase} */ (this.constructor)
2433
+ const ModelClass = frontendModelClassFor(this)
2336
2434
  const definitions = typeof ModelClass.relationshipDefinitions === "function" ? ModelClass.relationshipDefinitions() : {}
2337
2435
 
2338
2436
  for (const relationshipName of Object.keys(this._relationships)) {
@@ -2352,7 +2450,7 @@ export default class FrontendModelBase {
2352
2450
 
2353
2451
  /**
2354
2452
  * Runs resource path.
2355
- * @this {typeof FrontendModelBase}
2453
+ * @this {FrontendModelClass}
2356
2454
  * @returns {string} - Derived resource path.
2357
2455
  */
2358
2456
  static resourcePath() {
@@ -2364,7 +2462,7 @@ export default class FrontendModelBase {
2364
2462
 
2365
2463
  /**
2366
2464
  * Runs command name.
2367
- * @this {typeof FrontendModelBase}
2465
+ * @this {FrontendModelClass}
2368
2466
  * @param {FrontendModelCommandType} commandType - Command type.
2369
2467
  * @returns {string} - Resolved command name.
2370
2468
  */
@@ -2385,7 +2483,7 @@ export default class FrontendModelBase {
2385
2483
 
2386
2484
  /**
2387
2485
  * Runs normalize custom command payload arguments.
2388
- * @this {typeof FrontendModelBase}
2486
+ * @this {FrontendModelClass}
2389
2487
  * @param {Array<?>} args - Command arguments.
2390
2488
  * @returns {Record<string, ?>} - Command payload.
2391
2489
  */
@@ -2420,7 +2518,7 @@ export default class FrontendModelBase {
2420
2518
  * Returns the model name, preferring an explicit `static modelName` declaration
2421
2519
  * over the JavaScript class `.name` property. This allows minified builds to
2422
2520
  * preserve correct model names without relying on `keep_classnames`.
2423
- * @this {typeof FrontendModelBase}
2521
+ * @this {FrontendModelClass}
2424
2522
  * @returns {string} - The model name.
2425
2523
  */
2426
2524
  static getModelName() {
@@ -2712,7 +2810,7 @@ export default class FrontendModelBase {
2712
2810
 
2713
2811
  /**
2714
2812
  * Runs attributes from response.
2715
- * @this {typeof FrontendModelBase}
2813
+ * @this {FrontendModelClass}
2716
2814
  * @param {object} response - Response payload.
2717
2815
  * @returns {Record<string, ?>} - Attributes from payload.
2718
2816
  */
@@ -2724,7 +2822,7 @@ export default class FrontendModelBase {
2724
2822
 
2725
2823
  /**
2726
2824
  * Runs model data from response.
2727
- * @this {typeof FrontendModelBase}
2825
+ * @this {FrontendModelClass}
2728
2826
  * @param {object} response - Response payload.
2729
2827
  * @returns {{abilities: Record<string, boolean>, attributes: Record<string, ?>, associationCounts: Record<string, number>, queryData: Record<string, ?>, preloadedRelationships: Record<string, ?>, selectedAttributes: Set<string>}} - Attributes, preloaded relationships, association counts, queryData, abilities, and the selected-attributes set.
2730
2828
  */
@@ -2794,7 +2892,7 @@ export default class FrontendModelBase {
2794
2892
 
2795
2893
  /**
2796
2894
  * Runs apply preloaded relationships.
2797
- * @this {typeof FrontendModelBase}
2895
+ * @this {FrontendModelClass}
2798
2896
  * @param {FrontendModelBase} model - Model instance.
2799
2897
  * @param {Record<string, ?>} preloadedRelationships - Preloaded relationship payload.
2800
2898
  * @returns {void}
@@ -2815,9 +2913,9 @@ export default class FrontendModelBase {
2815
2913
 
2816
2914
  /**
2817
2915
  * Runs instantiate relationship value.
2818
- * @this {typeof FrontendModelBase}
2916
+ * @this {FrontendModelClass}
2819
2917
  * @param {?} relationshipPayload - Relationship payload value.
2820
- * @param {typeof FrontendModelBase | null} targetModelClass - Target model class.
2918
+ * @param {FrontendModelClass | null} targetModelClass - Target model class.
2821
2919
  * @returns {?} - Instantiated relationship value.
2822
2920
  */
2823
2921
  static instantiateRelationshipValue(relationshipPayload, targetModelClass) {
@@ -2830,7 +2928,7 @@ export default class FrontendModelBase {
2830
2928
 
2831
2929
  /**
2832
2930
  * Runs instantiate from response.
2833
- * @template {typeof FrontendModelBase} T
2931
+ * @template {FrontendModelClass} T
2834
2932
  * @this {T}
2835
2933
  * @param {Record<string, ?> | InstanceType<T>} response - Response payload, or an already-hydrated instance of this class.
2836
2934
  * @returns {InstanceType<T>} - New model instance, or the same instance unchanged if it was already hydrated.
@@ -2883,7 +2981,7 @@ export default class FrontendModelBase {
2883
2981
 
2884
2982
  /**
2885
2983
  * Runs find.
2886
- * @template {typeof FrontendModelBase} T
2984
+ * @template {FrontendModelClass} T
2887
2985
  * @this {T}
2888
2986
  * @param {number | string} id - Record identifier.
2889
2987
  * @returns {Promise<InstanceType<T>>} - Resolved model.
@@ -2894,7 +2992,7 @@ export default class FrontendModelBase {
2894
2992
 
2895
2993
  /**
2896
2994
  * Runs find by.
2897
- * @template {typeof FrontendModelBase} T
2995
+ * @template {FrontendModelClass} T
2898
2996
  * @this {T}
2899
2997
  * @param {Record<string, ?>} conditions - Attribute match conditions.
2900
2998
  * @returns {Promise<InstanceType<T> | null>} - Found model or null.
@@ -2905,7 +3003,7 @@ export default class FrontendModelBase {
2905
3003
 
2906
3004
  /**
2907
3005
  * Runs find by or fail.
2908
- * @template {typeof FrontendModelBase} T
3006
+ * @template {FrontendModelClass} T
2909
3007
  * @this {T}
2910
3008
  * @param {Record<string, ?>} conditions - Attribute match conditions.
2911
3009
  * @returns {Promise<InstanceType<T>>} - Found model.
@@ -2916,7 +3014,7 @@ export default class FrontendModelBase {
2916
3014
 
2917
3015
  /**
2918
3016
  * Runs to array.
2919
- * @template {typeof FrontendModelBase} T
3017
+ * @template {FrontendModelClass} T
2920
3018
  * @this {T}
2921
3019
  * @returns {Promise<InstanceType<T>[]>} - Loaded model instances.
2922
3020
  */
@@ -2926,7 +3024,7 @@ export default class FrontendModelBase {
2926
3024
 
2927
3025
  /**
2928
3026
  * Runs load.
2929
- * @template {typeof FrontendModelBase} T
3027
+ * @template {FrontendModelClass} T
2930
3028
  * @this {T}
2931
3029
  * @returns {Promise<InstanceType<T>[]>} - Loaded model instances.
2932
3030
  */
@@ -2936,7 +3034,7 @@ export default class FrontendModelBase {
2936
3034
 
2937
3035
  /**
2938
3036
  * Runs all.
2939
- * @template {typeof FrontendModelBase} T
3037
+ * @template {FrontendModelClass} T
2940
3038
  * @this {T}
2941
3039
  * @returns {FrontendModelQuery<T>} - Query builder.
2942
3040
  */
@@ -2946,7 +3044,7 @@ export default class FrontendModelBase {
2946
3044
 
2947
3045
  /**
2948
3046
  * Runs where.
2949
- * @template {typeof FrontendModelBase} T
3047
+ * @template {FrontendModelClass} T
2950
3048
  * @this {T}
2951
3049
  * @param {Record<string, ?>} conditions - Root-model where conditions.
2952
3050
  * @returns {import("./query.js").default<T>} - Query with where conditions.
@@ -2957,7 +3055,7 @@ export default class FrontendModelBase {
2957
3055
 
2958
3056
  /**
2959
3057
  * Runs joins.
2960
- * @template {typeof FrontendModelBase} T
3058
+ * @template {FrontendModelClass} T
2961
3059
  * @this {T}
2962
3060
  * @param {Record<string, ?> | Array<Record<string, ?>>} joins - Relationship descriptor joins.
2963
3061
  * @returns {import("./query.js").default<T>} - Query with joins.
@@ -2968,7 +3066,7 @@ export default class FrontendModelBase {
2968
3066
 
2969
3067
  /**
2970
3068
  * Runs limit.
2971
- * @template {typeof FrontendModelBase} T
3069
+ * @template {FrontendModelClass} T
2972
3070
  * @this {T}
2973
3071
  * @param {number} value - Maximum number of records.
2974
3072
  * @returns {import("./query.js").default<T>} - Query with limit.
@@ -2979,7 +3077,7 @@ export default class FrontendModelBase {
2979
3077
 
2980
3078
  /**
2981
3079
  * Runs offset.
2982
- * @template {typeof FrontendModelBase} T
3080
+ * @template {FrontendModelClass} T
2983
3081
  * @this {T}
2984
3082
  * @param {number} value - Number of records to skip.
2985
3083
  * @returns {import("./query.js").default<T>} - Query with offset.
@@ -2990,7 +3088,7 @@ export default class FrontendModelBase {
2990
3088
 
2991
3089
  /**
2992
3090
  * Runs page.
2993
- * @template {typeof FrontendModelBase} T
3091
+ * @template {FrontendModelClass} T
2994
3092
  * @this {T}
2995
3093
  * @param {number} pageNumber - 1-based page number.
2996
3094
  * @returns {import("./query.js").default<T>} - Query with page applied.
@@ -3001,7 +3099,7 @@ export default class FrontendModelBase {
3001
3099
 
3002
3100
  /**
3003
3101
  * Runs per page.
3004
- * @template {typeof FrontendModelBase} T
3102
+ * @template {FrontendModelClass} T
3005
3103
  * @this {T}
3006
3104
  * @param {number} value - Number of records per page.
3007
3105
  * @returns {import("./query.js").default<T>} - Query with page size.
@@ -3012,7 +3110,7 @@ export default class FrontendModelBase {
3012
3110
 
3013
3111
  /**
3014
3112
  * Runs count.
3015
- * @template {typeof FrontendModelBase} T
3113
+ * @template {FrontendModelClass} T
3016
3114
  * @this {T}
3017
3115
  * @returns {Promise<number>} - Number of loaded model instances.
3018
3116
  */
@@ -3026,8 +3124,8 @@ export default class FrontendModelBase {
3026
3124
  * accepted, future `create` events for this model are delivered
3027
3125
  * without re-checking per-record visibility. Query options can still
3028
3126
  * narrow which events reach this callback.
3029
- * @this {typeof FrontendModelBase}
3030
- * @param {(payload: {id: string, model: InstanceType<typeof FrontendModelBase>}) => void} callback - Event callback.
3127
+ * @this {FrontendModelClass}
3128
+ * @param {(payload: {id: string, model: FrontendModelBase}) => void} callback - Event callback.
3031
3129
  * @param {import("./query.js").FrontendModelEventOptions} [options] - Event query or record projection options.
3032
3130
  * @returns {Promise<() => void>} - Unsubscribe callback.
3033
3131
  */
@@ -3046,8 +3144,8 @@ export default class FrontendModelBase {
3046
3144
 
3047
3145
  /**
3048
3146
  * Class-level hook fired when any record of this model is updated.
3049
- * @this {typeof FrontendModelBase}
3050
- * @param {(payload: {id: string, model: InstanceType<typeof FrontendModelBase>}) => void} callback - Event callback.
3147
+ * @this {FrontendModelClass}
3148
+ * @param {(payload: {id: string, model: FrontendModelBase}) => void} callback - Event callback.
3051
3149
  * @param {import("./query.js").FrontendModelEventOptions} [options] - Event query or record projection options.
3052
3150
  * @returns {Promise<() => void>} - Unsubscribe callback.
3053
3151
  */
@@ -3066,7 +3164,7 @@ export default class FrontendModelBase {
3066
3164
 
3067
3165
  /**
3068
3166
  * Class-level hook fired when any record of this model is destroyed.
3069
- * @this {typeof FrontendModelBase}
3167
+ * @this {FrontendModelClass}
3070
3168
  * @param {(payload: {id: string}) => void} callback - Event callback.
3071
3169
  * @param {import("./query.js").FrontendModelEventOptions} [options] - Accepted for API symmetry; destroy events carry ids only.
3072
3170
  * @returns {Promise<() => void>} - Unsubscribe callback.
@@ -3091,7 +3189,7 @@ export default class FrontendModelBase {
3091
3189
  * instance's attributes are auto-merged with the broadcast payload
3092
3190
  * before the callback runs, so callers can read fresh values via
3093
3191
  * `this.someAttr()` without re-fetching.
3094
- * @param {(payload: {id: string, model: InstanceType<typeof FrontendModelBase>}) => void} callback - Event callback.
3192
+ * @param {(payload: {id: string, model: FrontendModelBase}) => void} callback - Event callback.
3095
3193
  * @param {import("./query.js").FrontendModelEventOptions} [options] - Event query or record projection options.
3096
3194
  * @returns {Promise<() => void>} - Unsubscribe callback.
3097
3195
  */
@@ -3099,9 +3197,7 @@ export default class FrontendModelBase {
3099
3197
  const self = /**
3100
3198
  * Narrows the runtime value to the documented type.
3101
3199
  @type {?} */ (this)
3102
- const ModelClass = /**
3103
- * Narrows the runtime value to the documented type.
3104
- @type {typeof FrontendModelBase} */ (this.constructor)
3200
+ const ModelClass = frontendModelClassFor(this)
3105
3201
  const sub = ensureFrontendModelEventSubscription(ModelClass)
3106
3202
  const id = String(self.id())
3107
3203
  const entry = {callback, ...frontendModelEventOptionsPayload(ModelClass, options)}
@@ -3133,9 +3229,7 @@ export default class FrontendModelBase {
3133
3229
  const self = /**
3134
3230
  * Narrows the runtime value to the documented type.
3135
3231
  @type {?} */ (this)
3136
- const ModelClass = /**
3137
- * Narrows the runtime value to the documented type.
3138
- @type {typeof FrontendModelBase} */ (this.constructor)
3232
+ const ModelClass = frontendModelClassFor(this)
3139
3233
 
3140
3234
  assertNoDestroyEventFilter(ModelClass, options)
3141
3235
 
@@ -3162,7 +3256,7 @@ export default class FrontendModelBase {
3162
3256
 
3163
3257
  /**
3164
3258
  * Runs pluck.
3165
- * @template {typeof FrontendModelBase} T
3259
+ * @template {FrontendModelClass} T
3166
3260
  * @this {T}
3167
3261
  * @param {...(string | string[] | Record<string, ?> | Array<Record<string, ?>>)} columns - Pluck definition(s).
3168
3262
  * @returns {Promise<Array<?>>} - Plucked values.
@@ -3173,7 +3267,7 @@ export default class FrontendModelBase {
3173
3267
 
3174
3268
  /**
3175
3269
  * Runs search.
3176
- * @template {typeof FrontendModelBase} T
3270
+ * @template {FrontendModelClass} T
3177
3271
  * @this {T}
3178
3272
  * @param {string[]} path - Relationship path.
3179
3273
  * @param {string} column - Column or attribute name.
@@ -3187,7 +3281,7 @@ export default class FrontendModelBase {
3187
3281
 
3188
3282
  /**
3189
3283
  * Runs ransack.
3190
- * @template {typeof FrontendModelBase} T
3284
+ * @template {FrontendModelClass} T
3191
3285
  * @this {T}
3192
3286
  * @param {Record<string, ?>} params - Ransack-style params hash.
3193
3287
  * @returns {FrontendModelQuery<T>} - Query builder with Ransack filters applied.
@@ -3198,7 +3292,7 @@ export default class FrontendModelBase {
3198
3292
 
3199
3293
  /**
3200
3294
  * Runs sort.
3201
- * @template {typeof FrontendModelBase} T
3295
+ * @template {FrontendModelClass} T
3202
3296
  * @this {T}
3203
3297
  * @param {string | string[] | string[][] | [string, string] | Array<[string, string]> | Record<string, ?> | Array<Record<string, ?>>} sort - Sort definition(s).
3204
3298
  * @returns {FrontendModelQuery<T>} - Query builder with sort definitions.
@@ -3209,7 +3303,7 @@ export default class FrontendModelBase {
3209
3303
 
3210
3304
  /**
3211
3305
  * Runs order.
3212
- * @template {typeof FrontendModelBase} T
3306
+ * @template {FrontendModelClass} T
3213
3307
  * @this {T}
3214
3308
  * @param {string | string[] | string[][] | [string, string] | Array<[string, string]> | Record<string, ?> | Array<Record<string, ?>>} sort - Sort definition(s).
3215
3309
  * @returns {FrontendModelQuery<T>} - Query builder with sort definitions.
@@ -3220,7 +3314,7 @@ export default class FrontendModelBase {
3220
3314
 
3221
3315
  /**
3222
3316
  * Runs group.
3223
- * @template {typeof FrontendModelBase} T
3317
+ * @template {FrontendModelClass} T
3224
3318
  * @this {T}
3225
3319
  * @param {string | string[] | Record<string, ?> | Array<Record<string, ?>>} group - Group definition(s).
3226
3320
  * @returns {FrontendModelQuery<T>} - Query builder with group definitions.
@@ -3231,7 +3325,7 @@ export default class FrontendModelBase {
3231
3325
 
3232
3326
  /**
3233
3327
  * Runs distinct.
3234
- * @template {typeof FrontendModelBase} T
3328
+ * @template {FrontendModelClass} T
3235
3329
  * @this {T}
3236
3330
  * @param {boolean} [value] - Whether to request distinct rows.
3237
3331
  * @returns {FrontendModelQuery<T>} - Query builder with distinct flag.
@@ -3242,7 +3336,7 @@ export default class FrontendModelBase {
3242
3336
 
3243
3337
  /**
3244
3338
  * Runs query.
3245
- * @template {typeof FrontendModelBase} T
3339
+ * @template {FrontendModelClass} T
3246
3340
  * @this {T}
3247
3341
  * @returns {FrontendModelQuery<T>} - Query builder.
3248
3342
  */
@@ -3252,7 +3346,7 @@ export default class FrontendModelBase {
3252
3346
 
3253
3347
  /**
3254
3348
  * Runs preload.
3255
- * @template {typeof FrontendModelBase} T
3349
+ * @template {FrontendModelClass} T
3256
3350
  * @this {T}
3257
3351
  * @param {import("../database/query/index.js").NestedPreloadRecord | string | Array<string | import("../database/query/index.js").NestedPreloadRecord>} preload - Preload graph.
3258
3352
  * @returns {FrontendModelQuery<T>} - Query with preload.
@@ -3263,7 +3357,7 @@ export default class FrontendModelBase {
3263
3357
 
3264
3358
  /**
3265
3359
  * Runs select.
3266
- * @template {typeof FrontendModelBase} T
3360
+ * @template {FrontendModelClass} T
3267
3361
  * @this {T}
3268
3362
  * @param {Record<string, string[] | string> | string | string[]} select - Model-aware attribute select map or root-model shorthand.
3269
3363
  * @returns {FrontendModelQuery<T>} - Query with selected attributes.
@@ -3274,7 +3368,7 @@ export default class FrontendModelBase {
3274
3368
 
3275
3369
  /**
3276
3370
  * Runs selects extra.
3277
- * @template {typeof FrontendModelBase} T
3371
+ * @template {FrontendModelClass} T
3278
3372
  * @this {T}
3279
3373
  * @param {Record<string, string[] | string> | string | string[]} select - Extra attributes to load in addition to the defaults, keyed by model name or root-model shorthand.
3280
3374
  * @returns {FrontendModelQuery<T>} - Query with extra selected attributes.
@@ -3285,7 +3379,7 @@ export default class FrontendModelBase {
3285
3379
 
3286
3380
  /**
3287
3381
  * Runs first.
3288
- * @template {typeof FrontendModelBase} T
3382
+ * @template {FrontendModelClass} T
3289
3383
  * @this {T}
3290
3384
  * @returns {Promise<InstanceType<T> | null>} - First model or null.
3291
3385
  */
@@ -3295,7 +3389,7 @@ export default class FrontendModelBase {
3295
3389
 
3296
3390
  /**
3297
3391
  * Runs last.
3298
- * @template {typeof FrontendModelBase} T
3392
+ * @template {FrontendModelClass} T
3299
3393
  * @this {T}
3300
3394
  * @returns {Promise<InstanceType<T> | null>} - Last model or null.
3301
3395
  */
@@ -3305,7 +3399,7 @@ export default class FrontendModelBase {
3305
3399
 
3306
3400
  /**
3307
3401
  * Runs find or initialize by.
3308
- * @template {typeof FrontendModelBase} T
3402
+ * @template {FrontendModelClass} T
3309
3403
  * @this {T}
3310
3404
  * @param {Record<string, ?>} conditions - Attribute match conditions.
3311
3405
  * @returns {Promise<InstanceType<T>>} - Existing or initialized model.
@@ -3316,7 +3410,7 @@ export default class FrontendModelBase {
3316
3410
 
3317
3411
  /**
3318
3412
  * Runs find or create by.
3319
- * @template {typeof FrontendModelBase} T
3413
+ * @template {FrontendModelClass} T
3320
3414
  * @this {T}
3321
3415
  * @param {Record<string, ?>} conditions - Attribute match conditions.
3322
3416
  * @param {(model: InstanceType<T>) => Promise<void> | void} [callback] - Optional callback before save when created.
@@ -3328,7 +3422,7 @@ export default class FrontendModelBase {
3328
3422
 
3329
3423
  /**
3330
3424
  * Runs create.
3331
- * @template {typeof FrontendModelBase} T
3425
+ * @template {FrontendModelClass} T
3332
3426
  * @this {T}
3333
3427
  * @param {Record<string, ?>} [attributes] - Initial attributes.
3334
3428
  * @returns {Promise<InstanceType<T>>} - Persisted model.
@@ -3345,7 +3439,7 @@ export default class FrontendModelBase {
3345
3439
 
3346
3440
  /**
3347
3441
  * Runs assert find by conditions.
3348
- * @this {typeof FrontendModelBase}
3442
+ * @this {FrontendModelClass}
3349
3443
  * @param {Record<string, ?>} conditions - findBy conditions.
3350
3444
  * @returns {void}
3351
3445
  */
@@ -3359,7 +3453,7 @@ export default class FrontendModelBase {
3359
3453
 
3360
3454
  /**
3361
3455
  * Runs matches find by conditions.
3362
- * @this {typeof FrontendModelBase}
3456
+ * @this {FrontendModelClass}
3363
3457
  * @param {FrontendModelBase} model - Candidate model.
3364
3458
  * @param {Record<string, ?>} conditions - Match conditions.
3365
3459
  * @returns {boolean} - Whether the model matches all conditions.
@@ -3389,7 +3483,7 @@ export default class FrontendModelBase {
3389
3483
 
3390
3484
  /**
3391
3485
  * Runs find by condition value matches.
3392
- * @this {typeof FrontendModelBase}
3486
+ * @this {FrontendModelClass}
3393
3487
  * @param {?} actualValue - Actual model value.
3394
3488
  * @param {?} expectedValue - Expected find condition value.
3395
3489
  * @returns {boolean} - Whether values match.
@@ -3457,7 +3551,7 @@ export default class FrontendModelBase {
3457
3551
 
3458
3552
  /**
3459
3553
  * Runs find by primitive values match.
3460
- * @this {typeof FrontendModelBase}
3554
+ * @this {FrontendModelClass}
3461
3555
  * @param {?} actualValue - Actual model value.
3462
3556
  * @param {?} expectedValue - Expected find condition value.
3463
3557
  * @returns {boolean} - Whether primitive values match after safe coercion.
@@ -3488,7 +3582,7 @@ export default class FrontendModelBase {
3488
3582
 
3489
3583
  /**
3490
3584
  * Runs find by numeric string matches number.
3491
- * @this {typeof FrontendModelBase}
3585
+ * @this {FrontendModelClass}
3492
3586
  * @param {string} numericString - Numeric string value.
3493
3587
  * @param {number} expectedNumber - Number value.
3494
3588
  * @returns {boolean} - Whether values represent the same number.
@@ -3511,50 +3605,9 @@ export default class FrontendModelBase {
3511
3605
  * @returns {Promise<this>} - Updated model.
3512
3606
  */
3513
3607
  async update(newAttributes = {}) {
3514
- const ModelClass = /**
3515
- * Narrows the runtime value to the documented type.
3516
- @type {typeof FrontendModelBase} */ (this.constructor)
3517
- const attachmentDefinitions = ModelClass.attachmentDefinitions()
3518
- /**
3519
- * Regular attributes.
3520
- @type {Record<string, ?>} */
3521
- const regularAttributes = {}
3522
- /**
3523
- * Pending attachments.
3524
- @type {Array<{attachmentName: string, value: ?}>} */
3525
- const pendingAttachments = []
3526
-
3527
- for (const [attributeName, attributeValue] of Object.entries(newAttributes)) {
3528
- if (attachmentDefinitions[attributeName]) {
3529
- if (attributeValue !== undefined && attributeValue !== null) {
3530
- pendingAttachments.push({attachmentName: attributeName, value: attributeValue})
3531
- }
3532
- } else {
3533
- regularAttributes[attributeName] = attributeValue
3534
- }
3535
- }
3536
-
3537
- if (Object.keys(regularAttributes).length > 0) {
3538
- this.assignAttributes(regularAttributes)
3539
- const changedAttributes = Object.fromEntries(
3540
- Object.entries(this.changes()).map(([attributeName, [, currentValue]]) => [attributeName, currentValue])
3541
- )
3542
-
3543
- const response = await ModelClass.executeCommand("update", {
3544
- attributes: changedAttributes,
3545
- id: this.primaryKeyValue()
3546
- })
3547
-
3548
- this.assignAttributes(ModelClass.attributesFromResponse(response))
3549
- this.setIsNewRecord(false)
3550
- this._persistedAttributes = cloneFrontendModelAttributes(this.attributes())
3551
- }
3608
+ this.assignAttributes(newAttributes)
3552
3609
 
3553
- for (const pendingAttachment of pendingAttachments) {
3554
- await this.getAttachmentByName(pendingAttachment.attachmentName).attach(pendingAttachment.value)
3555
- }
3556
-
3557
- return this
3610
+ return await this.save()
3558
3611
  }
3559
3612
 
3560
3613
  /**
@@ -3563,9 +3616,7 @@ export default class FrontendModelBase {
3563
3616
  * @returns {Promise<void>} - Resolves when attached.
3564
3617
  */
3565
3618
  async attach(attachmentInput) {
3566
- const ModelClass = /**
3567
- * Narrows the runtime value to the documented type.
3568
- @type {typeof FrontendModelBase} */ (this.constructor)
3619
+ const ModelClass = frontendModelClassFor(this)
3569
3620
  const attachmentDefinitions = ModelClass.attachmentDefinitions()
3570
3621
  const attachmentNames = Object.keys(attachmentDefinitions)
3571
3622
  let attachmentName = attachmentNames[0]
@@ -3597,9 +3648,7 @@ export default class FrontendModelBase {
3597
3648
  * @returns {Promise<this>} - Saved model.
3598
3649
  */
3599
3650
  async save() {
3600
- const ModelClass = /**
3601
- * Narrows the runtime value to the documented type.
3602
- @type {typeof FrontendModelBase} */ (this.constructor)
3651
+ const ModelClass = frontendModelClassFor(this)
3603
3652
  const isNew = this.isNewRecord()
3604
3653
  const commandType = isNew ? "create" : "update"
3605
3654
  /**
@@ -3613,17 +3662,25 @@ export default class FrontendModelBase {
3613
3662
  payload.id = this.primaryKeyValue()
3614
3663
  }
3615
3664
 
3616
- const nestedAttributes = this._buildNestedAttributesPayload()
3665
+ const nestedAttributes = await this._buildNestedAttributesPayload()
3617
3666
 
3618
3667
  if (nestedAttributes && Object.keys(nestedAttributes).length > 0) {
3619
3668
  payload.nestedAttributes = nestedAttributes
3620
3669
  }
3621
3670
 
3671
+ const attachments = await this._buildAttachmentsPayload()
3672
+
3673
+ if (Object.keys(attachments).length > 0) {
3674
+ payload.attachments = attachments
3675
+ }
3676
+
3622
3677
  const response = await ModelClass.executeCommand(commandType, payload)
3623
3678
 
3624
3679
  this.assignAttributes(ModelClass.attributesFromResponse(response))
3625
3680
  this.setIsNewRecord(false)
3626
3681
  this._persistedAttributes = cloneFrontendModelAttributes(this.attributes())
3682
+ this._pendingNestedAttributes = {}
3683
+ this._clearPendingAttachments()
3627
3684
 
3628
3685
  this._reconcileNestedAttributesFromResponse(response)
3629
3686
 
@@ -3668,15 +3725,39 @@ export default class FrontendModelBase {
3668
3725
  * @returns {Promise<void>} - Resolves when destroyed on backend.
3669
3726
  */
3670
3727
  async destroy() {
3671
- const ModelClass = /**
3672
- * Narrows the runtime value to the documented type.
3673
- @type {typeof FrontendModelBase} */ (this.constructor)
3728
+ const ModelClass = frontendModelClassFor(this)
3674
3729
 
3675
3730
  await ModelClass.executeCommand("destroy", {
3676
3731
  id: this.primaryKeyValue()
3677
3732
  })
3678
3733
  }
3679
3734
 
3735
+ /**
3736
+ * Builds the attachment payload queued on this model for the next save.
3737
+ * @returns {Promise<Record<string, ?>>} Attachment payload keyed by attachment name.
3738
+ */
3739
+ async _buildAttachmentsPayload() {
3740
+ /** @type {Record<string, ?>} */
3741
+ const payload = {}
3742
+
3743
+ for (const attachmentName of Object.keys(this._attachments)) {
3744
+ const attachmentPayload = await this._attachments[attachmentName].pendingAttachmentsPayload()
3745
+
3746
+ if (attachmentPayload !== undefined) {
3747
+ payload[attachmentName] = attachmentPayload
3748
+ }
3749
+ }
3750
+
3751
+ return payload
3752
+ }
3753
+
3754
+ /** Clears queued attachment inputs after a successful save. */
3755
+ _clearPendingAttachments() {
3756
+ for (const attachmentName of Object.keys(this._attachments)) {
3757
+ this._attachments[attachmentName].clearPendingAttachments()
3758
+ }
3759
+ }
3760
+
3680
3761
  /**
3681
3762
  * Walks relationships declared in this resource's `nestedAttributes` config
3682
3763
  * and builds the per-relationship payload of dirty children for a parent save.
@@ -3689,12 +3770,10 @@ export default class FrontendModelBase {
3689
3770
  *
3690
3771
  * Loaded but untouched records are omitted so nested save preserves Rails-style
3691
3772
  * "children not referenced in payload are left alone" semantics.
3692
- * @returns {Record<string, Array<Record<string, ?>>>} - Per-relationship list of nested-attribute entries.
3773
+ * @returns {Promise<Record<string, Array<Record<string, ?>>>>} - Per-relationship list of nested-attribute entries.
3693
3774
  */
3694
- _buildNestedAttributesPayload() {
3695
- const ModelClass = /**
3696
- * Narrows the runtime value to the documented type.
3697
- @type {typeof FrontendModelBase} */ (this.constructor)
3775
+ async _buildNestedAttributesPayload() {
3776
+ const ModelClass = frontendModelClassFor(this)
3698
3777
  const resourceConfig = typeof ModelClass.resourceConfig === "function" ? ModelClass.resourceConfig() : null
3699
3778
  const nestedAttributesConfig = resourceConfig?.nestedAttributes
3700
3779
 
@@ -3706,20 +3785,34 @@ export default class FrontendModelBase {
3706
3785
  const payload = {}
3707
3786
 
3708
3787
  for (const relationshipName of Object.keys(nestedAttributesConfig)) {
3788
+ /** @type {Array<Record<string, ?>>} */
3789
+ const entries = []
3709
3790
  const relationship = this._relationships[relationshipName]
3710
3791
 
3711
- if (!relationship || !(relationship instanceof FrontendModelHasManyRelationship)) continue
3712
- if (!Array.isArray(relationship._loadedValue) || relationship._loadedValue.length === 0) continue
3792
+ if (relationship instanceof FrontendModelHasManyRelationship && Array.isArray(relationship._loadedValue)) {
3793
+ for (const child of relationship._loadedValue) {
3794
+ const childEntry = await child._nestedAttributesEntryForParentSave()
3713
3795
 
3714
- /**
3715
- * Entries.
3716
- @type {Array<Record<string, ?>>} */
3717
- const entries = []
3796
+ if (childEntry) entries.push(childEntry)
3797
+ }
3798
+ } else if (relationship instanceof FrontendModelSingularRelationship && relationship.getPreloaded()) {
3799
+ const child = relationship.loaded()
3718
3800
 
3719
- for (const child of relationship._loadedValue) {
3720
- const childEntry = child._nestedAttributesEntryForParentSave()
3801
+ if (child instanceof FrontendModelBase) {
3802
+ const childEntry = await child._nestedAttributesEntryForParentSave()
3721
3803
 
3722
- if (childEntry) entries.push(childEntry)
3804
+ if (childEntry) entries.push(childEntry)
3805
+ }
3806
+ }
3807
+
3808
+ if (Object.prototype.hasOwnProperty.call(this._pendingNestedAttributes, relationshipName)) {
3809
+ entries.push(
3810
+ ...await this._nestedAttributesPayloadForSubmittedValue(
3811
+ ModelClass,
3812
+ relationshipName,
3813
+ this._pendingNestedAttributes[relationshipName]
3814
+ )
3815
+ )
3723
3816
  }
3724
3817
 
3725
3818
  if (entries.length > 0) {
@@ -3734,41 +3827,164 @@ export default class FrontendModelBase {
3734
3827
  * Builds the payload entry for this child when walked by a parent's
3735
3828
  * `_buildNestedAttributesPayload`. Returns `null` when the child has no
3736
3829
  * dirty state and no dirty descendants, so the parent can omit it.
3737
- * @returns {Record<string, ?> | null} - Nested-attribute entry or null if clean.
3830
+ * @returns {Promise<Record<string, ?> | null>} - Nested-attribute entry or null if clean.
3738
3831
  */
3739
- _nestedAttributesEntryForParentSave() {
3832
+ async _nestedAttributesEntryForParentSave() {
3740
3833
  if (this.markedForDestruction()) {
3741
3834
  if (this.isNewRecord()) return null
3742
3835
  return {id: this.primaryKeyValue(), _destroy: true}
3743
3836
  }
3744
3837
 
3745
- const nestedAttributes = this._buildNestedAttributesPayload()
3838
+ const nestedAttributes = await this._buildNestedAttributesPayload()
3746
3839
  const hasNestedDirty = Object.keys(nestedAttributes).length > 0
3840
+ const attachments = await this._buildAttachmentsPayload()
3841
+ const hasAttachments = Object.keys(attachments).length > 0
3747
3842
 
3748
3843
  if (this.isNewRecord()) {
3749
3844
  /**
3750
3845
  * Entry.
3751
3846
  @type {Record<string, ?>} */
3752
- const entry = {attributes: this.attributes()}
3847
+ const entry = {}
3848
+ const attributes = this._changedAttributesForSave()
3753
3849
 
3850
+ if (Object.keys(attributes).length > 0) entry.attributes = attributes
3851
+ if (hasAttachments) entry.attachments = attachments
3754
3852
  if (hasNestedDirty) entry.nestedAttributes = nestedAttributes
3755
3853
 
3756
3854
  return entry
3757
3855
  }
3758
3856
 
3759
- if (!this.isChanged() && !hasNestedDirty) return null
3857
+ if (!this.isChanged() && !hasNestedDirty && !hasAttachments) return null
3760
3858
 
3761
3859
  /**
3762
3860
  * Entry.
3763
3861
  @type {Record<string, ?>} */
3764
3862
  const entry = {id: this.primaryKeyValue()}
3765
3863
 
3766
- if (this.isChanged()) entry.attributes = this.attributes()
3864
+ if (this.isChanged()) entry.attributes = this._changedAttributesForSave()
3865
+ if (hasAttachments) entry.attachments = attachments
3767
3866
  if (hasNestedDirty) entry.nestedAttributes = nestedAttributes
3768
3867
 
3769
3868
  return entry
3770
3869
  }
3771
3870
 
3871
+ /**
3872
+ * Builds nested entries from a Rails-style submitted `*Attributes` value.
3873
+ * @param {FrontendModelClass} ModelClass - Parent model class.
3874
+ * @param {string} relationshipName - Nested relationship name.
3875
+ * @param {?} value - Submitted nested attributes value.
3876
+ * @returns {Promise<Array<Record<string, ?>>>} Nested entries for the transport payload.
3877
+ */
3878
+ async _nestedAttributesPayloadForSubmittedValue(ModelClass, relationshipName, value) {
3879
+ const relationshipDefinition = ModelClass.relationshipDefinition(relationshipName)
3880
+ const TargetModelClass = ModelClass.relationshipModelClass(relationshipName)
3881
+
3882
+ if (!relationshipDefinition) {
3883
+ throw new Error(`Unknown nested relationship: ${ModelClass.name}#${relationshipName}`)
3884
+ }
3885
+ if (!TargetModelClass) {
3886
+ throw new Error(`No target model class configured for ${ModelClass.name}#${relationshipName}`)
3887
+ }
3888
+
3889
+ if (relationshipTypeIsCollection(relationshipDefinition.type)) {
3890
+ if (!Array.isArray(value)) {
3891
+ throw new Error(`${ModelClass.name}#${relationshipName}Attributes must be an array`)
3892
+ }
3893
+
3894
+ return await Promise.all(
3895
+ value.map(async (entry) => await this._nestedAttributesEntryPayloadForSubmittedValue(TargetModelClass, entry))
3896
+ )
3897
+ }
3898
+
3899
+ if (value == null) return []
3900
+ if (Array.isArray(value)) {
3901
+ throw new Error(`${ModelClass.name}#${relationshipName}Attributes must be an object`)
3902
+ }
3903
+
3904
+ return [await this._nestedAttributesEntryPayloadForSubmittedValue(TargetModelClass, value)]
3905
+ }
3906
+
3907
+ /**
3908
+ * Converts one submitted Rails-style nested attributes object into transport payload shape.
3909
+ * @param {FrontendModelClass} ModelClass - Nested child model class.
3910
+ * @param {?} submittedEntry - Submitted nested attributes entry.
3911
+ * @returns {Promise<Record<string, ?>>} Transport nested-attributes entry.
3912
+ */
3913
+ async _nestedAttributesEntryPayloadForSubmittedValue(ModelClass, submittedEntry) {
3914
+ if (!frontendAttachmentValueIsPlainObject(submittedEntry)) {
3915
+ throw new Error(`${ModelClass.name} nested attributes entries must be objects`)
3916
+ }
3917
+
3918
+ /** @type {Record<string, ?>} */
3919
+ const entry = {}
3920
+ /** @type {Record<string, ?>} */
3921
+ const attributes = {}
3922
+ /** @type {Record<string, ?>} */
3923
+ const attachments = {}
3924
+ /** @type {Record<string, Array<Record<string, ?>>>} */
3925
+ const nestedAttributes = {}
3926
+
3927
+ for (const [attributeName, value] of Object.entries(submittedEntry)) {
3928
+ if (attributeName === "id" || attributeName === "_destroy") {
3929
+ entry[attributeName] = value
3930
+ continue
3931
+ }
3932
+
3933
+ const nestedRelationshipName = ModelClass.nestedAttributesRelationshipName(attributeName)
3934
+
3935
+ if (nestedRelationshipName) {
3936
+ nestedAttributes[nestedRelationshipName] = await this._nestedAttributesPayloadForSubmittedValue(
3937
+ ModelClass,
3938
+ nestedRelationshipName,
3939
+ value
3940
+ )
3941
+ continue
3942
+ }
3943
+
3944
+ if (ModelClass.attachmentDefinition(attributeName)) {
3945
+ attachments[attributeName] = await this._attachmentPayloadForSubmittedValue(ModelClass, attributeName, value)
3946
+ continue
3947
+ }
3948
+
3949
+ attributes[attributeName] = value
3950
+ }
3951
+
3952
+ if (Object.keys(attributes).length > 0) entry.attributes = attributes
3953
+ if (Object.keys(attachments).length > 0) entry.attachments = attachments
3954
+ if (Object.keys(nestedAttributes).length > 0) entry.nestedAttributes = nestedAttributes
3955
+
3956
+ return entry
3957
+ }
3958
+
3959
+ /**
3960
+ * Normalizes a submitted attachment value for transport.
3961
+ * @param {FrontendModelClass} ModelClass - Model class owning the attachment.
3962
+ * @param {string} attachmentName - Attachment name.
3963
+ * @param {?} value - Submitted attachment value.
3964
+ * @returns {Promise<Record<string, ?> | Record<string, ?>[]>} Normalized attachment payload.
3965
+ */
3966
+ async _attachmentPayloadForSubmittedValue(ModelClass, attachmentName, value) {
3967
+ const attachmentDefinition = ModelClass.attachmentDefinition(attachmentName)
3968
+
3969
+ if (attachmentDefinition?.type === "hasMany") {
3970
+ const values = Array.isArray(value) ? value : [value]
3971
+
3972
+ return await Promise.all(values.map(async (entry) => await normalizeFrontendAttachmentInput(entry)))
3973
+ }
3974
+
3975
+ if (Array.isArray(value)) {
3976
+ const lastValue = value[value.length - 1]
3977
+
3978
+ if (lastValue === undefined) {
3979
+ throw new Error(`${ModelClass.name}#${attachmentName} attachment array cannot be empty`)
3980
+ }
3981
+
3982
+ return await normalizeFrontendAttachmentInput(lastValue)
3983
+ }
3984
+
3985
+ return await normalizeFrontendAttachmentInput(value)
3986
+ }
3987
+
3772
3988
  /**
3773
3989
  * After a parent save with `nestedAttributes`, the server response includes
3774
3990
  * preloaded versions of the affected relationships. This replaces the local
@@ -3779,9 +3995,7 @@ export default class FrontendModelBase {
3779
3995
  * @returns {void}
3780
3996
  */
3781
3997
  _reconcileNestedAttributesFromResponse(response) {
3782
- const ModelClass = /**
3783
- * Narrows the runtime value to the documented type.
3784
- @type {typeof FrontendModelBase} */ (this.constructor)
3998
+ const ModelClass = frontendModelClassFor(this)
3785
3999
  const resourceConfig = typeof ModelClass.resourceConfig === "function" ? ModelClass.resourceConfig() : null
3786
4000
  const nestedAttributesConfig = resourceConfig?.nestedAttributes
3787
4001
 
@@ -3808,7 +4022,7 @@ export default class FrontendModelBase {
3808
4022
 
3809
4023
  /**
3810
4024
  * Runs execute command.
3811
- * @this {typeof FrontendModelBase}
4025
+ * @this {FrontendModelClass}
3812
4026
  * @param {FrontendModelCommandType} commandType - Command type.
3813
4027
  * @param {Record<string, ?>} payload - Command payload.
3814
4028
  * @returns {Promise<Record<string, ?>>} - Parsed JSON response.
@@ -3885,7 +4099,7 @@ export default class FrontendModelBase {
3885
4099
 
3886
4100
  /**
3887
4101
  * Runs execute custom command.
3888
- * @this {typeof FrontendModelBase}
4102
+ * @this {FrontendModelClass}
3889
4103
  * @param {object} args - Command arguments.
3890
4104
  * @param {string} args.commandName - Raw command path segment.
3891
4105
  * @param {FrontendModelRequestCommandType} args.commandType - Logical command type for error handling.
@@ -3933,7 +4147,7 @@ export default class FrontendModelBase {
3933
4147
 
3934
4148
  /**
3935
4149
  * Runs throw on error frontend model response.
3936
- * @this {typeof FrontendModelBase}
4150
+ * @this {FrontendModelClass}
3937
4151
  * @param {object} args - Arguments.
3938
4152
  * @param {FrontendModelRequestCommandType} args.commandType - Command type.
3939
4153
  * @param {Record<string, ?>} args.response - Decoded response.
@@ -3992,7 +4206,7 @@ export default class FrontendModelBase {
3992
4206
 
3993
4207
  /**
3994
4208
  * Runs configured frontend model attribute names.
3995
- * @this {typeof FrontendModelBase}
4209
+ * @this {FrontendModelClass}
3996
4210
  * @returns {Set<string>} - Configured frontend model attribute names.
3997
4211
  */
3998
4212
  static configuredFrontendModelAttributeNames() {