velocious 1.0.445 → 1.0.446

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 (36) hide show
  1. package/build/database/record/index.js +37 -37
  2. package/build/environment-handlers/node/cli/commands/generate/base-models.js +67 -1
  3. package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +169 -0
  4. package/build/frontend-model-controller.js +44 -12
  5. package/build/frontend-model-resource/base-resource.js +519 -129
  6. package/build/frontend-models/base.js +324 -118
  7. package/build/frontend-models/websocket-channel.js +39 -3
  8. package/build/src/database/record/index.d.ts +37 -37
  9. package/build/src/database/record/index.d.ts.map +1 -1
  10. package/build/src/database/record/index.js +38 -38
  11. package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts +13 -0
  12. package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts.map +1 -1
  13. package/build/src/environment-handlers/node/cli/commands/generate/base-models.js +59 -2
  14. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts +74 -0
  15. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
  16. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +155 -1
  17. package/build/src/frontend-model-controller.d.ts +2 -1
  18. package/build/src/frontend-model-controller.d.ts.map +1 -1
  19. package/build/src/frontend-model-controller.js +38 -14
  20. package/build/src/frontend-model-resource/base-resource.d.ts +196 -21
  21. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  22. package/build/src/frontend-model-resource/base-resource.js +467 -112
  23. package/build/src/frontend-models/base.d.ts +77 -4
  24. package/build/src/frontend-models/base.d.ts.map +1 -1
  25. package/build/src/frontend-models/base.js +278 -116
  26. package/build/src/frontend-models/websocket-channel.d.ts +8 -0
  27. package/build/src/frontend-models/websocket-channel.d.ts.map +1 -1
  28. package/build/src/frontend-models/websocket-channel.js +35 -4
  29. package/package.json +1 -1
  30. package/src/database/record/index.js +37 -37
  31. package/src/environment-handlers/node/cli/commands/generate/base-models.js +67 -1
  32. package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +169 -0
  33. package/src/frontend-model-controller.js +44 -12
  34. package/src/frontend-model-resource/base-resource.js +519 -129
  35. package/src/frontend-models/base.js +324 -118
  36. package/src/frontend-models/websocket-channel.js +39 -3
@@ -24,6 +24,10 @@ 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
@@ -614,6 +618,17 @@ function frontendModelPayloadContainsAttachmentUpload(value) {
614
618
  return Object.values(value).some((entry) => frontendModelPayloadContainsAttachmentUpload(entry))
615
619
  }
616
620
 
621
+ /**
622
+ * Returns the concrete frontend-model class for an instance.
623
+ * @param {FrontendModelBase} model - Frontend model instance.
624
+ * @returns {typeof FrontendModelBase} Concrete frontend-model class.
625
+ */
626
+ function frontendModelClassFor(model) {
627
+ const constructorValue = model.constructor
628
+
629
+ return /** @type {typeof FrontendModelBase} */ (constructorValue)
630
+ }
631
+
617
632
  /**
618
633
  * Runs normalize frontend attachment input.
619
634
  * @param {?} input - Attachment input.
@@ -691,6 +706,12 @@ async function normalizeFrontendAttachmentInput(input) {
691
706
  * Frontend-model attachment helper for one attachment name.
692
707
  */
693
708
  export class FrontendModelAttachmentHandle {
709
+ /**
710
+ * Pending attachment inputs queued for the next model save.
711
+ * @type {FrontendModelAttachmentInput[]}
712
+ */
713
+ pendingInputs = []
714
+
694
715
  /**
695
716
  * Runs constructor.
696
717
  * @param {object} args - Options.
@@ -702,15 +723,70 @@ export class FrontendModelAttachmentHandle {
702
723
  this.attachmentName = attachmentName
703
724
  }
704
725
 
726
+ /**
727
+ * Queue attachment input for the parent model's next save.
728
+ * @param {FrontendModelAttachmentInput | FrontendModelAttachmentInput[]} input - Attachment input.
729
+ * @returns {void}
730
+ */
731
+ queueAttach(input) {
732
+ const ModelClass = frontendModelClassFor(this.model)
733
+ const attachmentDefinition = ModelClass.attachmentDefinition(this.attachmentName)
734
+
735
+ if (attachmentDefinition?.type === "hasOne") {
736
+ if (Array.isArray(input)) {
737
+ const lastInput = input[input.length - 1]
738
+
739
+ this.pendingInputs = typeof lastInput === "undefined" ? [] : [lastInput]
740
+ } else {
741
+ this.pendingInputs = [input]
742
+ }
743
+ return
744
+ }
745
+
746
+ if (Array.isArray(input)) {
747
+ this.pendingInputs.push(...input)
748
+ } else {
749
+ this.pendingInputs.push(input)
750
+ }
751
+ }
752
+
753
+ /**
754
+ * Whether this attachment has queued inputs for the next model save.
755
+ * @returns {boolean} Whether any pending inputs exist.
756
+ */
757
+ hasPendingAttachments() {
758
+ return this.pendingInputs.length > 0
759
+ }
760
+
761
+ /**
762
+ * Builds the save payload for queued attachment inputs.
763
+ * @returns {Promise<Record<string, ?> | Record<string, ?>[] | undefined>} Normalized attachment payload.
764
+ */
765
+ async pendingAttachmentsPayload() {
766
+ if (this.pendingInputs.length === 0) return undefined
767
+
768
+ const ModelClass = frontendModelClassFor(this.model)
769
+ const attachmentDefinition = ModelClass.attachmentDefinition(this.attachmentName)
770
+
771
+ if (attachmentDefinition?.type === "hasMany") {
772
+ return await Promise.all(this.pendingInputs.map(async (input) => await normalizeFrontendAttachmentInput(input)))
773
+ }
774
+
775
+ return await normalizeFrontendAttachmentInput(this.pendingInputs[this.pendingInputs.length - 1])
776
+ }
777
+
778
+ /** Clears queued attachment inputs after a successful model save. */
779
+ clearPendingAttachments() {
780
+ this.pendingInputs = []
781
+ }
782
+
705
783
  /**
706
784
  * Runs attach.
707
785
  * @param {?} input - Attachment input.
708
786
  * @returns {Promise<void>} - Resolves when attached.
709
787
  */
710
788
  async attach(input) {
711
- const ModelClass = /**
712
- * Narrows the runtime value to the documented type.
713
- @type {typeof FrontendModelBase} */ (this.model.constructor)
789
+ const ModelClass = frontendModelClassFor(this.model)
714
790
  const normalizedInput = await normalizeFrontendAttachmentInput(input)
715
791
  const response = await ModelClass.executeCommand("attach", {
716
792
  attachment: normalizedInput,
@@ -727,9 +803,7 @@ export class FrontendModelAttachmentHandle {
727
803
  * @returns {Promise<FrontendModelAttachmentDownload | null>} - Downloaded attachment payload.
728
804
  */
729
805
  async download(attachmentId) {
730
- const ModelClass = /**
731
- * Narrows the runtime value to the documented type.
732
- @type {typeof FrontendModelBase} */ (this.model.constructor)
806
+ const ModelClass = frontendModelClassFor(this.model)
733
807
  const response = await ModelClass.executeCommand("download", frontendModelAttachmentCommandPayload(this, attachmentId))
734
808
  const attachmentPayload = response.attachment
735
809
 
@@ -755,9 +829,7 @@ export class FrontendModelAttachmentHandle {
755
829
  * @returns {Promise<string | null>} - Resolvable attachment URL.
756
830
  */
757
831
  async url(attachmentId) {
758
- const ModelClass = /**
759
- * Narrows the runtime value to the documented type.
760
- @type {typeof FrontendModelBase} */ (this.model.constructor)
832
+ const ModelClass = frontendModelClassFor(this.model)
761
833
  const response = await ModelClass.executeCommand("url", frontendModelAttachmentCommandPayload(this, attachmentId))
762
834
 
763
835
  if (typeof response.url === "string" && response.url.length > 0) {
@@ -772,9 +844,7 @@ export class FrontendModelAttachmentHandle {
772
844
  * @returns {string} - Download URL for this attachment on the configured backend.
773
845
  */
774
846
  downloadUrl() {
775
- const ModelClass = /**
776
- * Narrows the runtime value to the documented type.
777
- @type {typeof FrontendModelBase} */ (this.model.constructor)
847
+ const ModelClass = frontendModelClassFor(this.model)
778
848
  const commandName = ModelClass.commandName("download")
779
849
  const resourcePath = ModelClass.resourcePath()
780
850
  const commandUrl = frontendModelCommandUrl(resourcePath, commandName)
@@ -1654,6 +1724,11 @@ export default class FrontendModelBase {
1654
1724
  * Narrows the runtime value to the documented type.
1655
1725
  @type {Record<string, FrontendModelAttachmentHandle>} */
1656
1726
  _attachments
1727
+ /**
1728
+ * Rails-style nested attribute payloads queued for the next save.
1729
+ * @type {Record<string, ?>}
1730
+ */
1731
+ _pendingNestedAttributes
1657
1732
  /**
1658
1733
  * Narrows the runtime value to the documented type.
1659
1734
  @type {Set<string> | null} */
@@ -1681,14 +1756,13 @@ export default class FrontendModelBase {
1681
1756
  * @param {Record<string, ?>} [attributes] - Initial attributes.
1682
1757
  */
1683
1758
  constructor(attributes = {}) {
1684
- const ModelClass = /**
1685
- * Narrows the runtime value to the documented type.
1686
- @type {typeof FrontendModelBase} */ (this.constructor)
1759
+ const ModelClass = frontendModelClassFor(this)
1687
1760
 
1688
1761
  ModelClass.ensureGeneratedAttachmentMethods()
1689
1762
  this._attributes = {}
1690
1763
  this._relationships = {}
1691
1764
  this._attachments = {}
1765
+ this._pendingNestedAttributes = {}
1692
1766
  this._selectedAttributes = null
1693
1767
  this._isNewRecord = true
1694
1768
  this._markedForDestruction = false
@@ -1810,6 +1884,23 @@ export default class FrontendModelBase {
1810
1884
  return definitions[relationshipName] || null
1811
1885
  }
1812
1886
 
1887
+ /**
1888
+ * Resolves a Rails-style nested attributes key to a configured relationship.
1889
+ * @this {typeof FrontendModelBase}
1890
+ * @param {string} attributeName - Candidate attribute name, such as `tasksAttributes`.
1891
+ * @returns {string | null} Relationship name when nested attributes are configured.
1892
+ */
1893
+ static nestedAttributesRelationshipName(attributeName) {
1894
+ if (!attributeName.endsWith("Attributes")) return null
1895
+
1896
+ const relationshipName = attributeName.slice(0, -"Attributes".length)
1897
+ const nestedAttributesConfig = this.resourceConfig().nestedAttributes || {}
1898
+
1899
+ return Object.prototype.hasOwnProperty.call(nestedAttributesConfig, relationshipName)
1900
+ ? relationshipName
1901
+ : null
1902
+ }
1903
+
1813
1904
  /**
1814
1905
  * Runs relationship model class.
1815
1906
  * @this {typeof FrontendModelBase}
@@ -1915,9 +2006,7 @@ export default class FrontendModelBase {
1915
2006
  */
1916
2007
  getRelationshipByName(relationshipName) {
1917
2008
  if (!this._relationships[relationshipName]) {
1918
- const ModelClass = /**
1919
- * Narrows the runtime value to the documented type.
1920
- @type {typeof FrontendModelBase} */ (this.constructor)
2009
+ const ModelClass = frontendModelClassFor(this)
1921
2010
  const relationshipDefinition = ModelClass.relationshipDefinition(relationshipName)
1922
2011
  const targetModelClass = ModelClass.relationshipModelClass(relationshipName)
1923
2012
 
@@ -1937,9 +2026,7 @@ export default class FrontendModelBase {
1937
2026
  * @returns {FrontendModelAttachmentHandle} - Attachment helper.
1938
2027
  */
1939
2028
  getAttachmentByName(attachmentName) {
1940
- const ModelClass = /**
1941
- * Narrows the runtime value to the documented type.
1942
- @type {typeof FrontendModelBase} */ (this.constructor)
2029
+ const ModelClass = frontendModelClassFor(this)
1943
2030
  const attachmentDefinition = ModelClass.attachmentDefinition(attachmentName)
1944
2031
 
1945
2032
  if (!attachmentDefinition) {
@@ -1962,9 +2049,7 @@ export default class FrontendModelBase {
1962
2049
  * @returns {Promise<?>} - Loaded relationship value.
1963
2050
  */
1964
2051
  async loadRelationship(relationshipName) {
1965
- const ModelClass = /**
1966
- * Narrows the runtime value to the documented type.
1967
- @type {typeof FrontendModelBase} */ (this.constructor)
2052
+ const ModelClass = frontendModelClassFor(this)
1968
2053
  const id = this.primaryKeyValue()
1969
2054
  const reloadedModel = await ModelClass
1970
2055
  .preload([relationshipName])
@@ -2024,9 +2109,7 @@ export default class FrontendModelBase {
2024
2109
  async _tryCohortPreload(relationshipName) {
2025
2110
  if (!FrontendModelBase.getAutoload()) return false
2026
2111
 
2027
- const ModelClass = /**
2028
- * Narrows the runtime value to the documented type.
2029
- @type {typeof FrontendModelBase} */ (this.constructor)
2112
+ const ModelClass = frontendModelClassFor(this)
2030
2113
  const cohort = this._loadCohort
2031
2114
 
2032
2115
  if (!cohort || cohort.length <= 1) return false
@@ -2101,9 +2184,7 @@ export default class FrontendModelBase {
2101
2184
  * @returns {?} - Assigned relationship value.
2102
2185
  */
2103
2186
  setRelationship(relationshipName, relationshipValue) {
2104
- const ModelClass = /**
2105
- * Narrows the runtime value to the documented type.
2106
- @type {typeof FrontendModelBase} */ (this.constructor)
2187
+ const ModelClass = frontendModelClassFor(this)
2107
2188
  const relationshipDefinition = ModelClass.relationshipDefinition(relationshipName)
2108
2189
 
2109
2190
  if (!relationshipDefinition) {
@@ -2152,9 +2233,7 @@ export default class FrontendModelBase {
2152
2233
  * @returns {number | string} - Primary key value.
2153
2234
  */
2154
2235
  primaryKeyValue() {
2155
- const ModelClass = /**
2156
- * Narrows the runtime value to the documented type.
2157
- @type {typeof FrontendModelBase} */ (this.constructor)
2236
+ const ModelClass = frontendModelClassFor(this)
2158
2237
  const value = this.readAttribute(ModelClass.primaryKey())
2159
2238
 
2160
2239
  if (value === undefined || value === null) {
@@ -2296,6 +2375,19 @@ export default class FrontendModelBase {
2296
2375
  * @returns {?} - Assigned value.
2297
2376
  */
2298
2377
  setAttribute(attributeName, newValue) {
2378
+ const ModelClass = frontendModelClassFor(this)
2379
+ const nestedAttributesRelationshipName = ModelClass.nestedAttributesRelationshipName(attributeName)
2380
+
2381
+ if (nestedAttributesRelationshipName) {
2382
+ this._pendingNestedAttributes[nestedAttributesRelationshipName] = newValue
2383
+ return newValue
2384
+ }
2385
+
2386
+ if (ModelClass.attachmentDefinition(attributeName)) {
2387
+ this.getAttachmentByName(attributeName).queueAttach(newValue)
2388
+ return newValue
2389
+ }
2390
+
2299
2391
  const previousValue = this._attributes[attributeName]
2300
2392
 
2301
2393
  this._attributes[attributeName] = newValue
@@ -2330,9 +2422,7 @@ export default class FrontendModelBase {
2330
2422
  _invalidateRelationshipsForAttribute(attributeName) {
2331
2423
  if (!this._relationships || Object.keys(this._relationships).length === 0) return
2332
2424
 
2333
- const ModelClass = /**
2334
- * Narrows the runtime value to the documented type.
2335
- @type {typeof FrontendModelBase} */ (this.constructor)
2425
+ const ModelClass = frontendModelClassFor(this)
2336
2426
  const definitions = typeof ModelClass.relationshipDefinitions === "function" ? ModelClass.relationshipDefinitions() : {}
2337
2427
 
2338
2428
  for (const relationshipName of Object.keys(this._relationships)) {
@@ -3099,9 +3189,7 @@ export default class FrontendModelBase {
3099
3189
  const self = /**
3100
3190
  * Narrows the runtime value to the documented type.
3101
3191
  @type {?} */ (this)
3102
- const ModelClass = /**
3103
- * Narrows the runtime value to the documented type.
3104
- @type {typeof FrontendModelBase} */ (this.constructor)
3192
+ const ModelClass = frontendModelClassFor(this)
3105
3193
  const sub = ensureFrontendModelEventSubscription(ModelClass)
3106
3194
  const id = String(self.id())
3107
3195
  const entry = {callback, ...frontendModelEventOptionsPayload(ModelClass, options)}
@@ -3133,9 +3221,7 @@ export default class FrontendModelBase {
3133
3221
  const self = /**
3134
3222
  * Narrows the runtime value to the documented type.
3135
3223
  @type {?} */ (this)
3136
- const ModelClass = /**
3137
- * Narrows the runtime value to the documented type.
3138
- @type {typeof FrontendModelBase} */ (this.constructor)
3224
+ const ModelClass = frontendModelClassFor(this)
3139
3225
 
3140
3226
  assertNoDestroyEventFilter(ModelClass, options)
3141
3227
 
@@ -3511,50 +3597,9 @@ export default class FrontendModelBase {
3511
3597
  * @returns {Promise<this>} - Updated model.
3512
3598
  */
3513
3599
  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
- }
3552
-
3553
- for (const pendingAttachment of pendingAttachments) {
3554
- await this.getAttachmentByName(pendingAttachment.attachmentName).attach(pendingAttachment.value)
3555
- }
3600
+ this.assignAttributes(newAttributes)
3556
3601
 
3557
- return this
3602
+ return await this.save()
3558
3603
  }
3559
3604
 
3560
3605
  /**
@@ -3563,9 +3608,7 @@ export default class FrontendModelBase {
3563
3608
  * @returns {Promise<void>} - Resolves when attached.
3564
3609
  */
3565
3610
  async attach(attachmentInput) {
3566
- const ModelClass = /**
3567
- * Narrows the runtime value to the documented type.
3568
- @type {typeof FrontendModelBase} */ (this.constructor)
3611
+ const ModelClass = frontendModelClassFor(this)
3569
3612
  const attachmentDefinitions = ModelClass.attachmentDefinitions()
3570
3613
  const attachmentNames = Object.keys(attachmentDefinitions)
3571
3614
  let attachmentName = attachmentNames[0]
@@ -3597,9 +3640,7 @@ export default class FrontendModelBase {
3597
3640
  * @returns {Promise<this>} - Saved model.
3598
3641
  */
3599
3642
  async save() {
3600
- const ModelClass = /**
3601
- * Narrows the runtime value to the documented type.
3602
- @type {typeof FrontendModelBase} */ (this.constructor)
3643
+ const ModelClass = frontendModelClassFor(this)
3603
3644
  const isNew = this.isNewRecord()
3604
3645
  const commandType = isNew ? "create" : "update"
3605
3646
  /**
@@ -3613,17 +3654,25 @@ export default class FrontendModelBase {
3613
3654
  payload.id = this.primaryKeyValue()
3614
3655
  }
3615
3656
 
3616
- const nestedAttributes = this._buildNestedAttributesPayload()
3657
+ const nestedAttributes = await this._buildNestedAttributesPayload()
3617
3658
 
3618
3659
  if (nestedAttributes && Object.keys(nestedAttributes).length > 0) {
3619
3660
  payload.nestedAttributes = nestedAttributes
3620
3661
  }
3621
3662
 
3663
+ const attachments = await this._buildAttachmentsPayload()
3664
+
3665
+ if (Object.keys(attachments).length > 0) {
3666
+ payload.attachments = attachments
3667
+ }
3668
+
3622
3669
  const response = await ModelClass.executeCommand(commandType, payload)
3623
3670
 
3624
3671
  this.assignAttributes(ModelClass.attributesFromResponse(response))
3625
3672
  this.setIsNewRecord(false)
3626
3673
  this._persistedAttributes = cloneFrontendModelAttributes(this.attributes())
3674
+ this._pendingNestedAttributes = {}
3675
+ this._clearPendingAttachments()
3627
3676
 
3628
3677
  this._reconcileNestedAttributesFromResponse(response)
3629
3678
 
@@ -3668,15 +3717,39 @@ export default class FrontendModelBase {
3668
3717
  * @returns {Promise<void>} - Resolves when destroyed on backend.
3669
3718
  */
3670
3719
  async destroy() {
3671
- const ModelClass = /**
3672
- * Narrows the runtime value to the documented type.
3673
- @type {typeof FrontendModelBase} */ (this.constructor)
3720
+ const ModelClass = frontendModelClassFor(this)
3674
3721
 
3675
3722
  await ModelClass.executeCommand("destroy", {
3676
3723
  id: this.primaryKeyValue()
3677
3724
  })
3678
3725
  }
3679
3726
 
3727
+ /**
3728
+ * Builds the attachment payload queued on this model for the next save.
3729
+ * @returns {Promise<Record<string, ?>>} Attachment payload keyed by attachment name.
3730
+ */
3731
+ async _buildAttachmentsPayload() {
3732
+ /** @type {Record<string, ?>} */
3733
+ const payload = {}
3734
+
3735
+ for (const attachmentName of Object.keys(this._attachments)) {
3736
+ const attachmentPayload = await this._attachments[attachmentName].pendingAttachmentsPayload()
3737
+
3738
+ if (attachmentPayload !== undefined) {
3739
+ payload[attachmentName] = attachmentPayload
3740
+ }
3741
+ }
3742
+
3743
+ return payload
3744
+ }
3745
+
3746
+ /** Clears queued attachment inputs after a successful save. */
3747
+ _clearPendingAttachments() {
3748
+ for (const attachmentName of Object.keys(this._attachments)) {
3749
+ this._attachments[attachmentName].clearPendingAttachments()
3750
+ }
3751
+ }
3752
+
3680
3753
  /**
3681
3754
  * Walks relationships declared in this resource's `nestedAttributes` config
3682
3755
  * and builds the per-relationship payload of dirty children for a parent save.
@@ -3689,12 +3762,10 @@ export default class FrontendModelBase {
3689
3762
  *
3690
3763
  * Loaded but untouched records are omitted so nested save preserves Rails-style
3691
3764
  * "children not referenced in payload are left alone" semantics.
3692
- * @returns {Record<string, Array<Record<string, ?>>>} - Per-relationship list of nested-attribute entries.
3765
+ * @returns {Promise<Record<string, Array<Record<string, ?>>>>} - Per-relationship list of nested-attribute entries.
3693
3766
  */
3694
- _buildNestedAttributesPayload() {
3695
- const ModelClass = /**
3696
- * Narrows the runtime value to the documented type.
3697
- @type {typeof FrontendModelBase} */ (this.constructor)
3767
+ async _buildNestedAttributesPayload() {
3768
+ const ModelClass = frontendModelClassFor(this)
3698
3769
  const resourceConfig = typeof ModelClass.resourceConfig === "function" ? ModelClass.resourceConfig() : null
3699
3770
  const nestedAttributesConfig = resourceConfig?.nestedAttributes
3700
3771
 
@@ -3706,20 +3777,34 @@ export default class FrontendModelBase {
3706
3777
  const payload = {}
3707
3778
 
3708
3779
  for (const relationshipName of Object.keys(nestedAttributesConfig)) {
3780
+ /** @type {Array<Record<string, ?>>} */
3781
+ const entries = []
3709
3782
  const relationship = this._relationships[relationshipName]
3710
3783
 
3711
- if (!relationship || !(relationship instanceof FrontendModelHasManyRelationship)) continue
3712
- if (!Array.isArray(relationship._loadedValue) || relationship._loadedValue.length === 0) continue
3784
+ if (relationship instanceof FrontendModelHasManyRelationship && Array.isArray(relationship._loadedValue)) {
3785
+ for (const child of relationship._loadedValue) {
3786
+ const childEntry = await child._nestedAttributesEntryForParentSave()
3713
3787
 
3714
- /**
3715
- * Entries.
3716
- @type {Array<Record<string, ?>>} */
3717
- const entries = []
3788
+ if (childEntry) entries.push(childEntry)
3789
+ }
3790
+ } else if (relationship instanceof FrontendModelSingularRelationship && relationship.getPreloaded()) {
3791
+ const child = relationship.loaded()
3718
3792
 
3719
- for (const child of relationship._loadedValue) {
3720
- const childEntry = child._nestedAttributesEntryForParentSave()
3793
+ if (child instanceof FrontendModelBase) {
3794
+ const childEntry = await child._nestedAttributesEntryForParentSave()
3721
3795
 
3722
- if (childEntry) entries.push(childEntry)
3796
+ if (childEntry) entries.push(childEntry)
3797
+ }
3798
+ }
3799
+
3800
+ if (Object.prototype.hasOwnProperty.call(this._pendingNestedAttributes, relationshipName)) {
3801
+ entries.push(
3802
+ ...await this._nestedAttributesPayloadForSubmittedValue(
3803
+ ModelClass,
3804
+ relationshipName,
3805
+ this._pendingNestedAttributes[relationshipName]
3806
+ )
3807
+ )
3723
3808
  }
3724
3809
 
3725
3810
  if (entries.length > 0) {
@@ -3734,41 +3819,164 @@ export default class FrontendModelBase {
3734
3819
  * Builds the payload entry for this child when walked by a parent's
3735
3820
  * `_buildNestedAttributesPayload`. Returns `null` when the child has no
3736
3821
  * dirty state and no dirty descendants, so the parent can omit it.
3737
- * @returns {Record<string, ?> | null} - Nested-attribute entry or null if clean.
3822
+ * @returns {Promise<Record<string, ?> | null>} - Nested-attribute entry or null if clean.
3738
3823
  */
3739
- _nestedAttributesEntryForParentSave() {
3824
+ async _nestedAttributesEntryForParentSave() {
3740
3825
  if (this.markedForDestruction()) {
3741
3826
  if (this.isNewRecord()) return null
3742
3827
  return {id: this.primaryKeyValue(), _destroy: true}
3743
3828
  }
3744
3829
 
3745
- const nestedAttributes = this._buildNestedAttributesPayload()
3830
+ const nestedAttributes = await this._buildNestedAttributesPayload()
3746
3831
  const hasNestedDirty = Object.keys(nestedAttributes).length > 0
3832
+ const attachments = await this._buildAttachmentsPayload()
3833
+ const hasAttachments = Object.keys(attachments).length > 0
3747
3834
 
3748
3835
  if (this.isNewRecord()) {
3749
3836
  /**
3750
3837
  * Entry.
3751
3838
  @type {Record<string, ?>} */
3752
- const entry = {attributes: this.attributes()}
3839
+ const entry = {}
3840
+ const attributes = this._changedAttributesForSave()
3753
3841
 
3842
+ if (Object.keys(attributes).length > 0) entry.attributes = attributes
3843
+ if (hasAttachments) entry.attachments = attachments
3754
3844
  if (hasNestedDirty) entry.nestedAttributes = nestedAttributes
3755
3845
 
3756
3846
  return entry
3757
3847
  }
3758
3848
 
3759
- if (!this.isChanged() && !hasNestedDirty) return null
3849
+ if (!this.isChanged() && !hasNestedDirty && !hasAttachments) return null
3760
3850
 
3761
3851
  /**
3762
3852
  * Entry.
3763
3853
  @type {Record<string, ?>} */
3764
3854
  const entry = {id: this.primaryKeyValue()}
3765
3855
 
3766
- if (this.isChanged()) entry.attributes = this.attributes()
3856
+ if (this.isChanged()) entry.attributes = this._changedAttributesForSave()
3857
+ if (hasAttachments) entry.attachments = attachments
3767
3858
  if (hasNestedDirty) entry.nestedAttributes = nestedAttributes
3768
3859
 
3769
3860
  return entry
3770
3861
  }
3771
3862
 
3863
+ /**
3864
+ * Builds nested entries from a Rails-style submitted `*Attributes` value.
3865
+ * @param {typeof FrontendModelBase} ModelClass - Parent model class.
3866
+ * @param {string} relationshipName - Nested relationship name.
3867
+ * @param {?} value - Submitted nested attributes value.
3868
+ * @returns {Promise<Array<Record<string, ?>>>} Nested entries for the transport payload.
3869
+ */
3870
+ async _nestedAttributesPayloadForSubmittedValue(ModelClass, relationshipName, value) {
3871
+ const relationshipDefinition = ModelClass.relationshipDefinition(relationshipName)
3872
+ const TargetModelClass = ModelClass.relationshipModelClass(relationshipName)
3873
+
3874
+ if (!relationshipDefinition) {
3875
+ throw new Error(`Unknown nested relationship: ${ModelClass.name}#${relationshipName}`)
3876
+ }
3877
+ if (!TargetModelClass) {
3878
+ throw new Error(`No target model class configured for ${ModelClass.name}#${relationshipName}`)
3879
+ }
3880
+
3881
+ if (relationshipTypeIsCollection(relationshipDefinition.type)) {
3882
+ if (!Array.isArray(value)) {
3883
+ throw new Error(`${ModelClass.name}#${relationshipName}Attributes must be an array`)
3884
+ }
3885
+
3886
+ return await Promise.all(
3887
+ value.map(async (entry) => await this._nestedAttributesEntryPayloadForSubmittedValue(TargetModelClass, entry))
3888
+ )
3889
+ }
3890
+
3891
+ if (value == null) return []
3892
+ if (Array.isArray(value)) {
3893
+ throw new Error(`${ModelClass.name}#${relationshipName}Attributes must be an object`)
3894
+ }
3895
+
3896
+ return [await this._nestedAttributesEntryPayloadForSubmittedValue(TargetModelClass, value)]
3897
+ }
3898
+
3899
+ /**
3900
+ * Converts one submitted Rails-style nested attributes object into transport payload shape.
3901
+ * @param {typeof FrontendModelBase} ModelClass - Nested child model class.
3902
+ * @param {?} submittedEntry - Submitted nested attributes entry.
3903
+ * @returns {Promise<Record<string, ?>>} Transport nested-attributes entry.
3904
+ */
3905
+ async _nestedAttributesEntryPayloadForSubmittedValue(ModelClass, submittedEntry) {
3906
+ if (!frontendAttachmentValueIsPlainObject(submittedEntry)) {
3907
+ throw new Error(`${ModelClass.name} nested attributes entries must be objects`)
3908
+ }
3909
+
3910
+ /** @type {Record<string, ?>} */
3911
+ const entry = {}
3912
+ /** @type {Record<string, ?>} */
3913
+ const attributes = {}
3914
+ /** @type {Record<string, ?>} */
3915
+ const attachments = {}
3916
+ /** @type {Record<string, Array<Record<string, ?>>>} */
3917
+ const nestedAttributes = {}
3918
+
3919
+ for (const [attributeName, value] of Object.entries(submittedEntry)) {
3920
+ if (attributeName === "id" || attributeName === "_destroy") {
3921
+ entry[attributeName] = value
3922
+ continue
3923
+ }
3924
+
3925
+ const nestedRelationshipName = ModelClass.nestedAttributesRelationshipName(attributeName)
3926
+
3927
+ if (nestedRelationshipName) {
3928
+ nestedAttributes[nestedRelationshipName] = await this._nestedAttributesPayloadForSubmittedValue(
3929
+ ModelClass,
3930
+ nestedRelationshipName,
3931
+ value
3932
+ )
3933
+ continue
3934
+ }
3935
+
3936
+ if (ModelClass.attachmentDefinition(attributeName)) {
3937
+ attachments[attributeName] = await this._attachmentPayloadForSubmittedValue(ModelClass, attributeName, value)
3938
+ continue
3939
+ }
3940
+
3941
+ attributes[attributeName] = value
3942
+ }
3943
+
3944
+ if (Object.keys(attributes).length > 0) entry.attributes = attributes
3945
+ if (Object.keys(attachments).length > 0) entry.attachments = attachments
3946
+ if (Object.keys(nestedAttributes).length > 0) entry.nestedAttributes = nestedAttributes
3947
+
3948
+ return entry
3949
+ }
3950
+
3951
+ /**
3952
+ * Normalizes a submitted attachment value for transport.
3953
+ * @param {typeof FrontendModelBase} ModelClass - Model class owning the attachment.
3954
+ * @param {string} attachmentName - Attachment name.
3955
+ * @param {?} value - Submitted attachment value.
3956
+ * @returns {Promise<Record<string, ?> | Record<string, ?>[]>} Normalized attachment payload.
3957
+ */
3958
+ async _attachmentPayloadForSubmittedValue(ModelClass, attachmentName, value) {
3959
+ const attachmentDefinition = ModelClass.attachmentDefinition(attachmentName)
3960
+
3961
+ if (attachmentDefinition?.type === "hasMany") {
3962
+ const values = Array.isArray(value) ? value : [value]
3963
+
3964
+ return await Promise.all(values.map(async (entry) => await normalizeFrontendAttachmentInput(entry)))
3965
+ }
3966
+
3967
+ if (Array.isArray(value)) {
3968
+ const lastValue = value[value.length - 1]
3969
+
3970
+ if (lastValue === undefined) {
3971
+ throw new Error(`${ModelClass.name}#${attachmentName} attachment array cannot be empty`)
3972
+ }
3973
+
3974
+ return await normalizeFrontendAttachmentInput(lastValue)
3975
+ }
3976
+
3977
+ return await normalizeFrontendAttachmentInput(value)
3978
+ }
3979
+
3772
3980
  /**
3773
3981
  * After a parent save with `nestedAttributes`, the server response includes
3774
3982
  * preloaded versions of the affected relationships. This replaces the local
@@ -3779,9 +3987,7 @@ export default class FrontendModelBase {
3779
3987
  * @returns {void}
3780
3988
  */
3781
3989
  _reconcileNestedAttributesFromResponse(response) {
3782
- const ModelClass = /**
3783
- * Narrows the runtime value to the documented type.
3784
- @type {typeof FrontendModelBase} */ (this.constructor)
3990
+ const ModelClass = frontendModelClassFor(this)
3785
3991
  const resourceConfig = typeof ModelClass.resourceConfig === "function" ? ModelClass.resourceConfig() : null
3786
3992
  const nestedAttributesConfig = resourceConfig?.nestedAttributes
3787
3993