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
@@ -2,6 +2,7 @@
2
2
 
3
3
  import AuthorizationBaseResource from "../authorization/base-resource.js"
4
4
  import * as inflection from "inflection"
5
+ import isPlainObject from "../utils/plain-object.js"
5
6
 
6
7
  /**
7
8
  * FrontendModelResourceControllerArgs type.
@@ -25,6 +26,15 @@ import * as inflection from "inflection"
25
26
  * @property {import("../configuration-types.js").NormalizedFrontendModelResourceConfiguration | import("../configuration-types.js").FrontendModelResourceConfiguration} [resourceConfiguration] - Optional normalized resource configuration.
26
27
  */
27
28
 
29
+ /**
30
+ * Resolved frontend-model resource registration.
31
+ * @typedef {object} FrontendModelResolvedResourceConfiguration
32
+ * @property {import("../configuration-types.js").BackendProjectConfiguration} backendProject - Backend project owning the resource.
33
+ * @property {string} modelName - Frontend model name.
34
+ * @property {import("../configuration-types.js").FrontendModelResourceClassType} resourceClass - Resource class.
35
+ * @property {import("../configuration-types.js").NormalizedFrontendModelResourceConfiguration} resourceConfiguration - Normalized resource configuration.
36
+ */
37
+
28
38
  /**
29
39
  * Base class for backend frontend-model resources.
30
40
  * @template {typeof import("../database/record/index.js").default} [TModelClass=typeof import("../database/record/index.js").default]
@@ -320,16 +330,17 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
320
330
  /**
321
331
  * Runs create.
322
332
  * @param {Record<string, ?>} attributes - Create attributes.
323
- * @param {{controller?: ?, nestedAttributes?: Record<string, ?> | null}} [options] - Save options.
333
+ * @param {{attachments?: Record<string, ?> | null, controller?: ?, nestedAttributes?: Record<string, ?> | null}} [options] - Save options.
324
334
  * @returns {Promise<import("../database/record/index.js").default>} - Created model.
325
335
  */
326
336
  async create(attributes, options = {}) {
337
+ const attachmentSplit = this._extractAttachmentAttributes(attributes, options.attachments ?? null)
327
338
  const permit = parsePermittedParams(this.permittedParams({action: "create", ability: this.ability, locals: this.locals, params: attributes}))
328
- const filtered = filterWritableFrontendModelAttributes(this.modelClass().prototype, attributes, this, permit.attributes)
339
+ const filtered = filterWritableFrontendModelAttributes(this.modelClass().prototype, attachmentSplit.attributes, this, permit.attributes)
329
340
  const ModelClass = this.modelClass()
330
341
  const model = new ModelClass()
331
342
 
332
- return await this._saveWithNestedAttributes({filtered, model, options, permit})
343
+ return await this._saveWithNestedAttributes({filtered, model, options: {...options, attachments: attachmentSplit.attachments}, permit})
333
344
  }
334
345
 
335
346
  /**
@@ -345,24 +356,31 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
345
356
  * Runs update.
346
357
  * @param {import("../database/record/index.js").default} model - Existing model.
347
358
  * @param {Record<string, ?>} attributes - Update attributes.
348
- * @param {{controller?: ?, nestedAttributes?: Record<string, ?> | null}} [options] - Save options.
359
+ * @param {{attachments?: Record<string, ?> | null, controller?: ?, nestedAttributes?: Record<string, ?> | null}} [options] - Save options.
349
360
  * @returns {Promise<import("../database/record/index.js").default>} - Updated model.
350
361
  */
351
362
  async update(model, attributes, options = {}) {
363
+ const attachmentSplit = this._extractAttachmentAttributes(attributes, options.attachments ?? null)
352
364
  const permit = parsePermittedParams(this.permittedParams({action: "update", ability: this.ability, locals: this.locals, params: attributes}))
353
- const filtered = filterWritableFrontendModelAttributes(model, attributes, this, permit.attributes)
365
+ const filtered = filterWritableFrontendModelAttributes(model, attachmentSplit.attributes, this, permit.attributes)
354
366
 
355
- return await this._saveWithNestedAttributes({filtered, model, options, permit})
367
+ return await this._saveWithNestedAttributes({filtered, model, options: {...options, attachments: attachmentSplit.attachments}, permit})
356
368
  }
357
369
 
358
370
  /**
359
371
  * Saves a model and applies nested attributes in one transaction.
360
- * @param {{filtered: Record<string, ?>, model: import("../database/record/index.js").default, options: {controller?: ?, nestedAttributes?: Record<string, ?> | null}, permit: {attributes: string[], nested: Record<string, ?>}}} args - Save arguments.
372
+ * @param {{filtered: Record<string, ?>, model: import("../database/record/index.js").default, options: {attachments?: Record<string, ?> | null, controller?: ?, nestedAttributes?: Record<string, ?> | null}, permit: {attributes: string[], nested: Record<string, ?>}}} args - Save arguments.
361
373
  * @returns {Promise<import("../database/record/index.js").default>} - Saved model.
362
374
  */
363
375
  async _saveWithNestedAttributes({filtered, model, options, permit}) {
364
376
  await this.modelClass().transaction(async () => {
365
377
  await this._assignWithVirtualSetters(model, filtered)
378
+ this._assignAttachments(model, options.attachments ?? null, permit.attributes)
379
+
380
+ if (options.nestedAttributes) {
381
+ await this._applyBelongsToNestedAttributes(model, options.nestedAttributes, options.controller || null, permit)
382
+ }
383
+
366
384
  await model.save()
367
385
 
368
386
  if (options.nestedAttributes) {
@@ -415,6 +433,85 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
415
433
  }
416
434
  }
417
435
 
436
+ /**
437
+ * Splits attachment-named attributes into the attachment payload while preserving legacy callers
438
+ * that submitted attachments as normal frontend-model attributes.
439
+ * @param {Record<string, ?>} attributes - Incoming mutation attributes.
440
+ * @param {Record<string, ?> | null} attachments - Explicit attachment payload.
441
+ * @returns {{attributes: Record<string, ?>, attachments: Record<string, ?> | null}} Attributes with attachment keys removed and merged attachment payload.
442
+ */
443
+ _extractAttachmentAttributes(attributes, attachments) {
444
+ const attachmentDefinitions = this.modelClass().getAttachmentsMap?.() || {}
445
+ const attachmentNames = new Set(Object.keys(attachmentDefinitions))
446
+
447
+ if (attachmentNames.size === 0) return {attributes, attachments}
448
+
449
+ if (attachments !== null && !isPlainObject(attachments)) {
450
+ throw new Error("Expected attachments to be an object.")
451
+ }
452
+
453
+ /** @type {Record<string, ?>} */
454
+ const regularAttributes = {}
455
+ /** @type {Record<string, ?> | null} */
456
+ let mergedAttachments = attachments ? {...attachments} : null
457
+
458
+ for (const [attributeName, value] of Object.entries(attributes)) {
459
+ if (!attachmentNames.has(attributeName)) {
460
+ regularAttributes[attributeName] = value
461
+ continue
462
+ }
463
+
464
+ if (!mergedAttachments) mergedAttachments = {}
465
+ if (Object.prototype.hasOwnProperty.call(mergedAttachments, attributeName)) {
466
+ throw new Error(`Attachment '${attributeName}' was submitted in both attributes and attachments.`)
467
+ }
468
+
469
+ mergedAttachments[attributeName] = value
470
+ }
471
+
472
+ return {attributes: regularAttributes, attachments: mergedAttachments}
473
+ }
474
+
475
+ /**
476
+ * Queues attachment payloads on a model after validating permits and attachment definitions.
477
+ * @param {import("../database/record/index.js").default} model - Model receiving attachments.
478
+ * @param {Record<string, ?> | null} attachments - Attachments keyed by attachment name.
479
+ * @param {string[]} permittedAttributeNames - Attribute/attachment names permitted by the resource.
480
+ * @returns {void}
481
+ */
482
+ _assignAttachments(model, attachments, permittedAttributeNames) {
483
+ if (!attachments) return
484
+ if (!isPlainObject(attachments)) throw new Error("Expected attachments to be an object.")
485
+
486
+ const permitSet = new Set(permittedAttributeNames)
487
+ const modelClass = model.getModelClass()
488
+ const attachmentDefinitions = modelClass.getAttachmentsMap?.() || {}
489
+ /** @type {string[]} */
490
+ const notPermittedAttachments = []
491
+ /** @type {string[]} */
492
+ const invalidAttachments = []
493
+
494
+ for (const [attachmentName, value] of Object.entries(attachments)) {
495
+ if (!permitSet.has(attachmentName)) {
496
+ notPermittedAttachments.push(attachmentName)
497
+ continue
498
+ }
499
+ if (!attachmentDefinitions[attachmentName]) {
500
+ invalidAttachments.push(attachmentName)
501
+ continue
502
+ }
503
+
504
+ model.getAttachmentByName(attachmentName).queueAttach(value)
505
+ }
506
+
507
+ if (notPermittedAttachments.length > 0) {
508
+ throw new Error(`Frontend model attachment names not permitted by permittedParams(): ${notPermittedAttachments.join(", ")}`)
509
+ }
510
+ if (invalidAttachments.length > 0) {
511
+ throw new Error(`Invalid frontend model attachment names: ${invalidAttachments.join(", ")}`)
512
+ }
513
+ }
514
+
418
515
  /**
419
516
  * Sets a translated attribute on a model via the translations relationship.
420
517
  * @param {import("../database/record/index.js").default} model - Model instance.
@@ -487,6 +584,278 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
487
584
  return await this.typedControllerInstance().serializeFrontendModel(model)
488
585
  }
489
586
 
587
+ /**
588
+ * Resolves common metadata for one nested-attributes relationship.
589
+ * @param {object} args - Nested relationship inputs.
590
+ * @param {import("../database/record/index.js").default} args.parent - Parent model instance.
591
+ * @param {string} args.relationshipName - Relationship receiving nested attributes.
592
+ * @param {?} args.rawEntries - Raw nested entries from the request payload.
593
+ * @param {{attributes: string[], nested: Record<string, ?>}} args.childPermit - Parsed child permit.
594
+ * @param {?} args.controller - Controller instance for child resource lookup.
595
+ * @returns {{ability: import("../authorization/ability.js").default | undefined, childResource: FrontendModelBaseResource, childResourceConfig: FrontendModelResolvedResourceConfiguration, childWritableAttributes: string[], destroyPermitted: boolean, entries: Array<Record<string, ?>>, relationship: import("../database/record/relationships/base.js").default, targetModelClass: typeof import("../database/record/index.js").default}} Nested relationship context.
596
+ */
597
+ _nestedRelationshipContext({parent, relationshipName, rawEntries, childPermit, controller}) {
598
+ const parentModelClass = parent.getModelClass()
599
+ const modelAcceptance = parentModelClass.acceptedNestedAttributesFor?.(relationshipName)
600
+
601
+ if (!modelAcceptance) {
602
+ throw new Error(`Model ${parentModelClass.name} does not accept nested attributes for '${relationshipName}'. Declare it via ${parentModelClass.name}.acceptsNestedAttributesFor('${relationshipName}').`)
603
+ }
604
+
605
+ const relationship = parentModelClass.getRelationshipByName(relationshipName)
606
+ const relationshipType = relationship.getType()
607
+ const rawNormalizedEntries = this._nestedRelationshipEntries({rawEntries, relationshipName, relationshipType})
608
+ const destroyPermitted = childPermit.attributes.includes("_destroy")
609
+
610
+ if (destroyPermitted && !modelAcceptance.allowDestroy) {
611
+ throw new Error(`Resource permits _destroy on nestedAttributes['${relationshipName}'] but the model ${parentModelClass.name} does not allow destroy for that relationship. Set {allowDestroy: true} on ${parentModelClass.name}.acceptsNestedAttributesFor('${relationshipName}', ...).`)
612
+ }
613
+ if (typeof modelAcceptance.limit === "number" && rawNormalizedEntries.length > modelAcceptance.limit) {
614
+ throw new Error(`nestedAttributes['${relationshipName}'] exceeds model-declared limit of ${modelAcceptance.limit}.`)
615
+ }
616
+ if (relationshipType !== "hasMany" && rawNormalizedEntries.length > 1) {
617
+ throw new Error(`nestedAttributes['${relationshipName}'] accepts one entry for ${relationshipType} relationships.`)
618
+ }
619
+
620
+ const targetModelClass = relationship.getTargetModelClass()
621
+
622
+ if (!targetModelClass) {
623
+ throw new Error(`No target model class resolved for relationship '${relationshipName}' on ${parentModelClass.name}.`)
624
+ }
625
+
626
+ const childResourceConfig = controller?.frontendModelResourceConfigurationForModelClass?.(targetModelClass)
627
+
628
+ if (!childResourceConfig) {
629
+ throw new Error(`No frontend-model resource registered for child model '${targetModelClass.getModelName?.() || targetModelClass.name}' under relationship '${relationshipName}'.`)
630
+ }
631
+
632
+ const childResource = new childResourceConfig.resourceClass({
633
+ ability: this.ability,
634
+ controller,
635
+ context: this.context || {},
636
+ locals: this.locals || {},
637
+ modelClass: targetModelClass,
638
+ modelName: childResourceConfig.modelName,
639
+ params: controller?.frontendModelParams?.() || {},
640
+ resourceConfiguration: childResourceConfig.resourceConfiguration
641
+ })
642
+ const childWritableAttributes = childPermit.attributes.filter((name) => name !== "_destroy")
643
+ const entries = rawNormalizedEntries
644
+ .map((entry) => this._normalizeNestedRelationshipEntry({childPermit, entry, relationshipName, targetModelClass}))
645
+ .filter((entry) => {
646
+ if (typeof modelAcceptance.rejectIf !== "function") return true
647
+
648
+ return !modelAcceptance.rejectIf(isPlainObject(entry.attributes) ? entry.attributes : {})
649
+ })
650
+
651
+ return {
652
+ ability: controller?.currentAbility?.() || this.ability,
653
+ childResource,
654
+ childResourceConfig,
655
+ childWritableAttributes,
656
+ destroyPermitted,
657
+ entries,
658
+ relationship,
659
+ targetModelClass
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Normalizes nested entries for collection and singular relationships.
665
+ * @param {object} args - Nested entries inputs.
666
+ * @param {?} args.rawEntries - Raw nested entries value.
667
+ * @param {string} args.relationshipName - Relationship name.
668
+ * @param {string} args.relationshipType - Relationship type.
669
+ * @returns {Array<Record<string, ?>>} Normalized nested entry objects.
670
+ */
671
+ _nestedRelationshipEntries({rawEntries, relationshipName, relationshipType}) {
672
+ if (relationshipType === "hasMany") {
673
+ if (!Array.isArray(rawEntries)) {
674
+ throw new Error(`Expected array for nestedAttributes['${relationshipName}'] but got: ${typeof rawEntries}`)
675
+ }
676
+
677
+ return rawEntries.map((entry) => {
678
+ if (!isPlainObject(entry)) throw new Error(`nestedAttributes['${relationshipName}'] entries must be objects.`)
679
+
680
+ return entry
681
+ })
682
+ }
683
+
684
+ if (rawEntries == null) return []
685
+ if (Array.isArray(rawEntries)) {
686
+ return rawEntries.map((entry) => {
687
+ if (!isPlainObject(entry)) throw new Error(`nestedAttributes['${relationshipName}'] entries must be objects.`)
688
+
689
+ return entry
690
+ })
691
+ }
692
+ if (!isPlainObject(rawEntries)) {
693
+ throw new Error(`Expected object for nestedAttributes['${relationshipName}'] but got: ${typeof rawEntries}`)
694
+ }
695
+
696
+ return [rawEntries]
697
+ }
698
+
699
+ /**
700
+ * Normalizes one nested entry from either internal transport shape
701
+ * (`{attributes, attachments, nestedAttributes}`) or direct Rails-style
702
+ * fields (`{name, file, commentsAttributes}`).
703
+ * @param {object} args - Normalization inputs.
704
+ * @param {{attributes: string[], nested: Record<string, ?>}} args.childPermit - Parsed child permit spec.
705
+ * @param {Record<string, ?>} args.entry - Raw nested entry.
706
+ * @param {string} args.relationshipName - Relationship name for error messages.
707
+ * @param {typeof import("../database/record/index.js").default} args.targetModelClass - Child model class.
708
+ * @returns {Record<string, ?>} Normalized nested entry.
709
+ */
710
+ _normalizeNestedRelationshipEntry({childPermit, entry, relationshipName, targetModelClass}) {
711
+ /** @type {Record<string, ?>} */
712
+ const attributes = {}
713
+ /** @type {Record<string, ?>} */
714
+ const attachments = {}
715
+ /** @type {Record<string, ?>} */
716
+ const nestedAttributes = {}
717
+ /** @type {Record<string, ?>} */
718
+ const normalized = {}
719
+ const attachmentDefinitions = targetModelClass.getAttachmentsMap?.() || {}
720
+
721
+ for (const [attributeName, value] of Object.entries(entry)) {
722
+ if (attributeName === "id" || attributeName === "_destroy") {
723
+ normalized[attributeName] = value
724
+ continue
725
+ }
726
+
727
+ if (attributeName === "attributes") {
728
+ if (!isPlainObject(value)) throw new Error(`nestedAttributes['${relationshipName}'] entry attributes must be an object.`)
729
+ Object.assign(attributes, value)
730
+ continue
731
+ }
732
+
733
+ if (attributeName === "attachments") {
734
+ if (!isPlainObject(value)) throw new Error(`nestedAttributes['${relationshipName}'] entry attachments must be an object.`)
735
+ Object.assign(attachments, value)
736
+ continue
737
+ }
738
+
739
+ if (attributeName === "nestedAttributes") {
740
+ if (!isPlainObject(value)) throw new Error(`nestedAttributes['${relationshipName}'] entry nestedAttributes must be an object.`)
741
+ Object.assign(nestedAttributes, value)
742
+ continue
743
+ }
744
+
745
+ if (attributeName.endsWith("Attributes")) {
746
+ const nestedRelationshipName = attributeName.slice(0, -"Attributes".length)
747
+
748
+ if (!nestedRelationshipName) throw new Error(`Invalid nested attributes key: ${attributeName}`)
749
+ if (!childPermit.nested[nestedRelationshipName]) {
750
+ throw new Error(`Nested attributes for '${nestedRelationshipName}' are not permitted under '${relationshipName}'. Include {${attributeName}: [...]} in that nested permit.`)
751
+ }
752
+
753
+ nestedAttributes[nestedRelationshipName] = value
754
+ continue
755
+ }
756
+
757
+ if (attachmentDefinitions[attributeName]) {
758
+ attachments[attributeName] = value
759
+ } else {
760
+ attributes[attributeName] = value
761
+ }
762
+ }
763
+
764
+ if (Object.keys(attributes).length > 0) normalized.attributes = attributes
765
+ if (Object.keys(attachments).length > 0) normalized.attachments = attachments
766
+ if (Object.keys(nestedAttributes).length > 0) normalized.nestedAttributes = nestedAttributes
767
+
768
+ return normalized
769
+ }
770
+
771
+ /**
772
+ * Applies belongs-to nested attributes before the parent save so the parent FK can be set.
773
+ * @param {import("../database/record/index.js").default} parent - Parent model instance.
774
+ * @param {Record<string, ?>} nestedAttributes - Nested-attribute payload keyed by relationship name.
775
+ * @param {?} controller - Controller instance for resource resolution and authorization.
776
+ * @param {{attributes: string[], nested: Record<string, ?>} | null} [parentPermit] - Parsed parent permit spec.
777
+ * @returns {Promise<void>}
778
+ */
779
+ async _applyBelongsToNestedAttributes(parent, nestedAttributes, controller, parentPermit = null) {
780
+ const resolvedParent = parentPermit
781
+ || parsePermittedParams(this.permittedParams({action: "update", ability: this.ability, locals: this.locals, params: {}}))
782
+
783
+ for (const relationshipName of Object.keys(nestedAttributes)) {
784
+ const childPermit = resolvedParent.nested[relationshipName]
785
+
786
+ if (!childPermit) continue
787
+
788
+ const context = this._nestedRelationshipContext({
789
+ childPermit,
790
+ controller,
791
+ parent,
792
+ rawEntries: nestedAttributes[relationshipName],
793
+ relationshipName
794
+ })
795
+
796
+ if (context.relationship.getType() !== "belongsTo") continue
797
+
798
+ const foreignKey = this._foreignKeyAttributeForModel(context.relationship, parent.getModelClass())
799
+
800
+ for (const entry of context.entries) {
801
+ if (entry._destroy) {
802
+ if (!context.destroyPermitted) {
803
+ throw new Error(`nestedAttributes['${relationshipName}'] entry requested _destroy but "_destroy" is not in the permit for this relationship.`)
804
+ }
805
+ if (!entry.id) throw new Error(`nestedAttributes['${relationshipName}'] _destroy entry is missing an id.`)
806
+
807
+ const existing = await this._findNestedRecord({
808
+ ability: context.ability,
809
+ action: "destroy",
810
+ childResourceConfiguration: context.childResourceConfig.resourceConfiguration,
811
+ id: entry.id,
812
+ relationshipName,
813
+ targetModelClass: context.targetModelClass
814
+ })
815
+
816
+ await context.childResource.destroy(existing)
817
+ parent.setAttribute(foreignKey, null)
818
+ continue
819
+ }
820
+
821
+ const child = entry.id
822
+ ? await this._findNestedRecord({
823
+ ability: context.ability,
824
+ action: "update",
825
+ childResourceConfiguration: context.childResourceConfig.resourceConfiguration,
826
+ id: entry.id,
827
+ relationshipName,
828
+ targetModelClass: context.targetModelClass
829
+ })
830
+ : new context.targetModelClass()
831
+
832
+ await context.childResource._assignNestedEntryToChild({
833
+ child,
834
+ childWritableAttributes: context.childWritableAttributes,
835
+ entry
836
+ })
837
+ await context.childResource._applyBelongsToNestedAttributes(child, entry.nestedAttributes || {}, controller, childPermit)
838
+ await child.save()
839
+
840
+ if (!entry.id) {
841
+ await this._authorizeCreatedChild({
842
+ ability: context.ability,
843
+ child,
844
+ childResourceConfiguration: context.childResourceConfig.resourceConfiguration,
845
+ relationshipName,
846
+ targetModelClass: context.targetModelClass
847
+ })
848
+ }
849
+
850
+ if (entry.nestedAttributes) {
851
+ await context.childResource._applyNestedAttributes(child, entry.nestedAttributes, controller, childPermit)
852
+ }
853
+
854
+ parent.setAttribute(foreignKey, child.id())
855
+ }
856
+ }
857
+ }
858
+
490
859
  /**
491
860
  * Applies a `nestedAttributes` payload to a freshly-saved parent model,
492
861
  * cascading create/update/destroy writes across the declared relationships.
@@ -516,76 +885,29 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
516
885
  throw new Error(`Nested attributes for '${relationshipName}' are not permitted by ${this.constructor.name}.permittedParams(). Include {${relationshipName}Attributes: [...]} in the returned permit.`)
517
886
  }
518
887
 
519
- const entries = nestedAttributes[relationshipName]
520
-
521
- if (!Array.isArray(entries)) {
522
- throw new Error(`Expected array for nestedAttributes['${relationshipName}'] but got: ${typeof entries}`)
523
- }
524
-
525
- const parentModelClass = /**
526
- * Narrows the runtime value to the documented type.
527
- @type {?} */ (parent.getModelClass())
528
- const modelAcceptance = parentModelClass.acceptedNestedAttributesFor?.(relationshipName)
529
-
530
- if (!modelAcceptance) {
531
- throw new Error(`Model ${parentModelClass.name} does not accept nested attributes for '${relationshipName}'. Declare it via ${parentModelClass.name}.acceptsNestedAttributesFor('${relationshipName}').`)
532
- }
533
-
534
- const destroyPermitted = childPermit.attributes.includes("_destroy")
535
-
536
- if (destroyPermitted && !modelAcceptance.allowDestroy) {
537
- throw new Error(`Resource permits _destroy on nestedAttributes['${relationshipName}'] but the model ${parentModelClass.name} does not allow destroy for that relationship. Set {allowDestroy: true} on ${parentModelClass.name}.acceptsNestedAttributesFor('${relationshipName}', ...).`)
538
- }
539
-
540
- if (typeof modelAcceptance.limit === "number" && entries.length > modelAcceptance.limit) {
541
- throw new Error(`nestedAttributes['${relationshipName}'] exceeds model-declared limit of ${modelAcceptance.limit}.`)
542
- }
543
-
544
- const parentRelationship = parent.getRelationshipByName(relationshipName)
545
- const relationshipDefinitions = parentModelClass.relationships?.() || {}
546
- const definition = relationshipDefinitions[relationshipName]
547
-
548
- if (!definition || definition.type !== "hasMany") {
549
- throw new Error(`Nested attributes for '${relationshipName}' require a hasMany relationship. v1 does not support '${definition?.type}'.`)
550
- }
551
-
552
- const targetModelClass = /**
553
- * Narrows the runtime value to the documented type.
554
- @type {?} */ (parent.getModelClass()).relationshipModelClass?.(relationshipName)
555
-
556
- if (!targetModelClass) {
557
- throw new Error(`No target model class resolved for relationship '${relationshipName}' on ${parent.getModelClass().name}.`)
558
- }
559
-
560
- const childResourceConfig = controller?.frontendModelResourceConfigurationForModelClass?.(targetModelClass)
561
-
562
- if (!childResourceConfig) {
563
- throw new Error(`No frontend-model resource registered for child model '${targetModelClass.getModelName?.() || targetModelClass.name}' under relationship '${relationshipName}'.`)
564
- }
565
-
566
- const childResource = new childResourceConfig.resourceClass({
567
- ability: this.ability,
888
+ const context = this._nestedRelationshipContext({
889
+ childPermit,
568
890
  controller,
569
- context: this.context || {},
570
- locals: this.locals || {},
571
- modelClass: targetModelClass,
572
- modelName: childResourceConfig.modelName,
573
- params: controller?.frontendModelParams?.() || {},
574
- resourceConfiguration: childResourceConfig.resourceConfiguration
891
+ parent,
892
+ rawEntries: nestedAttributes[relationshipName],
893
+ relationshipName
575
894
  })
576
895
 
577
- const foreignKey = definition.foreignKey || this._inferForeignKey(parent, definition)
578
- const ability = controller?.currentAbility?.()
896
+ if (context.relationship.getType() === "belongsTo") continue
897
+
898
+ const parentLinkAttributes = this._parentLinkAttributesForNestedChild({
899
+ parent,
900
+ relationship: context.relationship,
901
+ targetModelClass: context.targetModelClass
902
+ })
579
903
 
580
904
  const destroyEntries = []
581
905
  const updateEntries = []
582
906
  const createEntries = []
583
907
 
584
- for (const entry of entries) {
585
- if (typeof modelAcceptance.rejectIf === "function" && modelAcceptance.rejectIf(entry?.attributes || {})) continue
586
-
908
+ for (const entry of context.entries) {
587
909
  if (entry?._destroy) {
588
- if (!destroyPermitted) {
910
+ if (!context.destroyPermitted) {
589
911
  throw new Error(`nestedAttributes['${relationshipName}'] entry requested _destroy but "_destroy" is not in the permit for this relationship.`)
590
912
  }
591
913
  if (!entry.id) {
@@ -599,90 +921,173 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
599
921
  }
600
922
  }
601
923
 
602
- // The permit's attribute list governs what child fields can be written.
603
- // Exclude `_destroy` from the writable set since it's a control flag,
604
- // not an attribute on the record.
605
- const childWritableAttributes = /**
606
- * Narrows the runtime value to the documented type.
607
- @type {string[]} */ (childPermit.attributes).filter((name) => name !== "_destroy")
608
-
609
924
  for (const entry of destroyEntries) {
610
925
  const existing = await this._findScopedChild({
611
- ability,
926
+ ability: context.ability,
612
927
  action: "destroy",
613
- childResourceConfiguration: childResourceConfig.resourceConfiguration,
614
- foreignKey,
928
+ childResourceConfiguration: context.childResourceConfig.resourceConfiguration,
615
929
  id: entry.id,
616
930
  parent,
931
+ parentLinkAttributes,
617
932
  relationshipName,
618
- targetModelClass
933
+ targetModelClass: context.targetModelClass
619
934
  })
620
935
 
621
- await childResource.destroy(existing)
936
+ await context.childResource.destroy(existing)
622
937
  }
623
938
 
624
939
  for (const entry of updateEntries) {
625
940
  const existing = await this._findScopedChild({
626
- ability,
941
+ ability: context.ability,
627
942
  action: "update",
628
- childResourceConfiguration: childResourceConfig.resourceConfiguration,
629
- foreignKey,
943
+ childResourceConfiguration: context.childResourceConfig.resourceConfiguration,
630
944
  id: entry.id,
631
945
  parent,
946
+ parentLinkAttributes,
632
947
  relationshipName,
633
- targetModelClass
948
+ targetModelClass: context.targetModelClass
634
949
  })
635
950
 
636
- if (entry.attributes && typeof entry.attributes === "object") {
637
- const filtered = filterWritableFrontendModelAttributes(existing, entry.attributes, childResource, childWritableAttributes)
638
- await /**
639
- * Narrows the runtime value to the documented type.
640
- @type {?} */ (childResource)._assignWithVirtualSetters(existing, filtered)
641
- await existing.save()
642
- }
951
+ await context.childResource._assignNestedEntryToChild({
952
+ child: existing,
953
+ childWritableAttributes: context.childWritableAttributes,
954
+ entry
955
+ })
956
+ await context.childResource._applyBelongsToNestedAttributes(existing, entry.nestedAttributes || {}, controller, childPermit)
957
+ await existing.save()
643
958
 
644
959
  if (entry.nestedAttributes) {
645
- await /**
646
- * Narrows the runtime value to the documented type.
647
- @type {?} */ (childResource)._applyNestedAttributes(existing, entry.nestedAttributes, controller, childPermit)
960
+ await context.childResource._applyNestedAttributes(existing, entry.nestedAttributes, controller, childPermit)
648
961
  }
649
962
  }
650
963
 
651
964
  for (const entry of createEntries) {
652
- const childAttributes = entry?.attributes && typeof entry.attributes === "object" ? entry.attributes : {}
653
-
654
- const child = parentRelationship.build({...childAttributes, [foreignKey]: parent.id()})
965
+ const child = new context.targetModelClass()
655
966
 
656
- const filtered = filterWritableFrontendModelAttributes(child, childAttributes, childResource, childWritableAttributes)
657
-
658
- await /**
659
- * Narrows the runtime value to the documented type.
660
- @type {?} */ (childResource)._assignWithVirtualSetters(child, filtered)
967
+ child.assign(parentLinkAttributes)
968
+ await context.childResource._assignNestedEntryToChild({
969
+ child,
970
+ childWritableAttributes: context.childWritableAttributes,
971
+ entry
972
+ })
973
+ await context.childResource._applyBelongsToNestedAttributes(child, entry.nestedAttributes || {}, controller, childPermit)
661
974
  await child.save()
662
975
 
663
976
  await this._authorizeCreatedChild({
664
- ability,
977
+ ability: context.ability,
665
978
  child,
666
- childResourceConfiguration: childResourceConfig.resourceConfiguration,
979
+ childResourceConfiguration: context.childResourceConfig.resourceConfiguration,
667
980
  relationshipName,
668
- targetModelClass
981
+ targetModelClass: context.targetModelClass
669
982
  })
670
983
 
671
984
  if (entry.nestedAttributes) {
672
- await /**
673
- * Narrows the runtime value to the documented type.
674
- @type {?} */ (childResource)._applyNestedAttributes(child, entry.nestedAttributes, controller, childPermit)
985
+ await context.childResource._applyNestedAttributes(child, entry.nestedAttributes, controller, childPermit)
675
986
  }
676
987
  }
677
988
  }
678
989
  }
679
990
 
991
+ /**
992
+ * Assigns one nested entry's attributes and attachments to a child model.
993
+ * @param {object} args - Assignment inputs.
994
+ * @param {import("../database/record/index.js").default} args.child - Child model receiving data.
995
+ * @param {string[]} args.childWritableAttributes - Permitted child attribute and attachment names.
996
+ * @param {Record<string, ?>} args.entry - Nested entry payload.
997
+ * @returns {Promise<void>}
998
+ */
999
+ async _assignNestedEntryToChild({child, childWritableAttributes, entry}) {
1000
+ if (entry.attributes !== undefined) {
1001
+ if (!isPlainObject(entry.attributes)) throw new Error("Expected nested entry attributes to be an object.")
1002
+
1003
+ const filtered = filterWritableFrontendModelAttributes(child, entry.attributes, this, childWritableAttributes)
1004
+ await this._assignWithVirtualSetters(child, filtered)
1005
+ }
1006
+
1007
+ if (entry.attachments !== undefined && !isPlainObject(entry.attachments)) {
1008
+ throw new Error("Expected nested entry attachments to be an object.")
1009
+ }
1010
+
1011
+ this._assignAttachments(child, entry.attachments ?? null, childWritableAttributes)
1012
+ }
1013
+
1014
+ /**
1015
+ * Converts a relationship's foreign-key column/name to the target model's attribute name.
1016
+ * @param {import("../database/record/relationships/base.js").default} relationship - Relationship metadata.
1017
+ * @param {typeof import("../database/record/index.js").default} modelClass - Model class containing the FK.
1018
+ * @returns {string} Foreign-key attribute name.
1019
+ */
1020
+ _foreignKeyAttributeForModel(relationship, modelClass) {
1021
+ const foreignKey = relationship.getForeignKey()
1022
+
1023
+ return modelClass.getColumnNameToAttributeNameMap()[foreignKey] || foreignKey
1024
+ }
1025
+
1026
+ /**
1027
+ * Returns the FK attributes that bind a nested child to its parent.
1028
+ * @param {object} args - Parent-link inputs.
1029
+ * @param {import("../database/record/index.js").default} args.parent - Parent model instance.
1030
+ * @param {import("../database/record/relationships/base.js").default} args.relationship - Relationship metadata.
1031
+ * @param {typeof import("../database/record/index.js").default} args.targetModelClass - Child model class.
1032
+ * @returns {Record<string, string | number>} Attributes that scope the child to the parent.
1033
+ */
1034
+ _parentLinkAttributesForNestedChild({parent, relationship, targetModelClass}) {
1035
+ const foreignKey = this._foreignKeyAttributeForModel(relationship, targetModelClass)
1036
+ /** @type {Record<string, string | number>} */
1037
+ const attributes = {[foreignKey]: /** @type {string | number} */ (parent.id())}
1038
+
1039
+ if (relationship.getPolymorphic()) {
1040
+ const typeAttribute = this._polymorphicTypeAttributeForModel(relationship, targetModelClass)
1041
+
1042
+ attributes[typeAttribute] = parent.getModelClass().getModelName()
1043
+ }
1044
+
1045
+ return attributes
1046
+ }
1047
+
1048
+ /**
1049
+ * Converts a relationship's polymorphic type column/name to a child attribute name.
1050
+ * @param {import("../database/record/relationships/base.js").default} relationship - Relationship metadata.
1051
+ * @param {typeof import("../database/record/index.js").default} modelClass - Model class containing the type column.
1052
+ * @returns {string} Polymorphic type attribute name.
1053
+ */
1054
+ _polymorphicTypeAttributeForModel(relationship, modelClass) {
1055
+ const typeColumn = relationship.getPolymorphicTypeColumn()
1056
+
1057
+ return modelClass.getColumnNameToAttributeNameMap()[typeColumn] || typeColumn
1058
+ }
1059
+
1060
+ /**
1061
+ * Finds an authorized nested record by id without parent scoping.
1062
+ * @param {object} args - Lookup inputs.
1063
+ * @param {import("../authorization/ability.js").default | undefined} args.ability - Current ability.
1064
+ * @param {"update" | "destroy"} args.action - Frontend action.
1065
+ * @param {import("../configuration-types.js").NormalizedFrontendModelResourceConfiguration} args.childResourceConfiguration - Child resource configuration.
1066
+ * @param {string | number} args.id - Child id from the payload.
1067
+ * @param {string} args.relationshipName - Parent's relationship name for error messages.
1068
+ * @param {typeof import("../database/record/index.js").default} args.targetModelClass - Child model class.
1069
+ * @returns {Promise<import("../database/record/index.js").default>} Authorized child model.
1070
+ */
1071
+ async _findNestedRecord({ability, action, childResourceConfiguration, id, relationshipName, targetModelClass}) {
1072
+ const primaryKey = targetModelClass.primaryKey()
1073
+ const query = ability
1074
+ ? targetModelClass.accessibleFor(this._resolveChildAbilityAction(childResourceConfiguration, action), ability)
1075
+ : targetModelClass.where({})
1076
+ const existing = await query.findBy({[primaryKey]: id})
1077
+
1078
+ if (!existing) {
1079
+ throw new Error(`Cannot ${action} nested ${relationshipName}[id=${id}]: record not found or not authorized.`)
1080
+ }
1081
+
1082
+ return existing
1083
+ }
1084
+
680
1085
  /**
681
1086
  * Resolves the ability action for a child resource using the child's own
682
1087
  * `abilities` mapping — never the parent controller's. This preserves
683
1088
  * custom mappings like `{update: "manage"}` and catches unmapped actions
684
1089
  * instead of silently defaulting to the raw action name.
685
- * @param {import("../configuration-types.js").FrontendModelResourceConfiguration} childResourceConfiguration - Child resource configuration.
1090
+ * @param {import("../configuration-types.js").NormalizedFrontendModelResourceConfiguration} childResourceConfiguration - Child resource configuration.
686
1091
  * @param {"create" | "update" | "destroy"} action - Frontend action.
687
1092
  * @returns {string} - Ability action for the child resource.
688
1093
  */
@@ -713,17 +1118,17 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
713
1118
  * @param {object} args - Arguments.
714
1119
  * @param {import("../authorization/ability.js").default | undefined} args.ability - Current ability.
715
1120
  * @param {"update" | "destroy"} args.action - Frontend action.
716
- * @param {import("../configuration-types.js").FrontendModelResourceConfiguration} args.childResourceConfiguration - Child resource configuration.
717
- * @param {string} args.foreignKey - Foreign-key attribute on the child pointing to the parent.
1121
+ * @param {import("../configuration-types.js").NormalizedFrontendModelResourceConfiguration} args.childResourceConfiguration - Child resource configuration.
718
1122
  * @param {string | number} args.id - Child id from the payload.
719
1123
  * @param {import("../database/record/index.js").default} args.parent - Parent model instance.
1124
+ * @param {Record<string, string | number>} args.parentLinkAttributes - Attributes that scope the child to the parent.
720
1125
  * @param {string} args.relationshipName - Parent's relationship name (for error messages).
721
1126
  * @param {typeof import("../database/record/index.js").default} args.targetModelClass - Child model class.
722
1127
  * @returns {Promise<import("../database/record/index.js").default>} - Authorized, parent-linked child model.
723
1128
  */
724
- async _findScopedChild({ability, action, childResourceConfiguration, foreignKey, id, parent, relationshipName, targetModelClass}) {
1129
+ async _findScopedChild({ability, action, childResourceConfiguration, id, parent, parentLinkAttributes, relationshipName, targetModelClass}) {
725
1130
  const primaryKey = targetModelClass.primaryKey()
726
- const lookup = {[primaryKey]: id, [foreignKey]: parent.id()}
1131
+ const lookup = {[primaryKey]: id, ...parentLinkAttributes}
727
1132
  const query = ability
728
1133
  ? /**
729
1134
  * Narrows the runtime value to the documented type.
@@ -748,7 +1153,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
748
1153
  * @param {object} args - Arguments.
749
1154
  * @param {import("../authorization/ability.js").default | undefined} args.ability - Current ability.
750
1155
  * @param {import("../database/record/index.js").default} args.child - Child model instance just created.
751
- * @param {import("../configuration-types.js").FrontendModelResourceConfiguration} args.childResourceConfiguration - Child resource configuration.
1156
+ * @param {import("../configuration-types.js").NormalizedFrontendModelResourceConfiguration} args.childResourceConfiguration - Child resource configuration.
752
1157
  * @param {string} args.relationshipName - Parent's relationship name (for error messages).
753
1158
  * @param {typeof import("../database/record/index.js").default} args.targetModelClass - Child model class.
754
1159
  * @returns {Promise<void>}
@@ -770,21 +1175,6 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
770
1175
  }
771
1176
  }
772
1177
 
773
- /**
774
- * Best-effort foreign-key inference for relationships that don't declare it.
775
- * @param {import("../database/record/index.js").default} parent - Parent model.
776
- * @param {{foreignKey?: string}} definition - Relationship definition.
777
- * @returns {string} - Foreign-key attribute name.
778
- */
779
- _inferForeignKey(parent, definition) {
780
- if (definition.foreignKey) return definition.foreignKey
781
-
782
- const parentModelName = parent.getModelClass().name || ""
783
- const underscored = parentModelName.replace(/([A-Z])/g, (match, letter, index) => (index === 0 ? letter.toLowerCase() : `_${letter.toLowerCase()}`))
784
-
785
- return `${inflection.camelize(underscored, true)}Id`
786
- }
787
-
788
1178
  /**
789
1179
  * After nested writes, preload every relationship declared in the
790
1180
  * parent's permit so the post-save serialize step emits them and the