velocious 1.0.447 → 1.0.449

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 (69) hide show
  1. package/README.md +1 -1
  2. package/build/configuration-types.js +2 -0
  3. package/build/database/pool/async-tracked-multi-connection.js +123 -8
  4. package/build/database/pool/base.js +14 -1
  5. package/build/database/record/index.js +13 -8
  6. package/build/environment-handlers/node/cli/commands/generate/base-models.js +1 -16
  7. package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +125 -73
  8. package/build/frontend-model-controller.js +9 -0
  9. package/build/frontend-model-resource/base-resource.js +266 -53
  10. package/build/frontend-models/base.js +241 -97
  11. package/build/frontend-models/preloader.js +3 -2
  12. package/build/src/background-jobs/job-record.d.ts +2 -1
  13. package/build/src/background-jobs/job-record.d.ts.map +1 -1
  14. package/build/src/configuration-types.d.ts +10 -0
  15. package/build/src/configuration-types.d.ts.map +1 -1
  16. package/build/src/configuration-types.js +3 -1
  17. package/build/src/database/pool/async-tracked-multi-connection.d.ts +60 -4
  18. package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
  19. package/build/src/database/pool/async-tracked-multi-connection.js +113 -9
  20. package/build/src/database/pool/base.d.ts +38 -1
  21. package/build/src/database/pool/base.d.ts.map +1 -1
  22. package/build/src/database/pool/base.js +14 -2
  23. package/build/src/database/query/preloader/belongs-to.d.ts +2 -2
  24. package/build/src/database/query/preloader/belongs-to.d.ts.map +1 -1
  25. package/build/src/database/query/preloader/has-many.d.ts +1 -1
  26. package/build/src/database/query/preloader/has-many.d.ts.map +1 -1
  27. package/build/src/database/query/preloader/has-one.d.ts +2 -2
  28. package/build/src/database/query/preloader/has-one.d.ts.map +1 -1
  29. package/build/src/database/query/preloader.d.ts +1 -1
  30. package/build/src/database/query/preloader.d.ts.map +1 -1
  31. package/build/src/database/record/attachments/handle.d.ts +1 -1
  32. package/build/src/database/record/attachments/handle.d.ts.map +1 -1
  33. package/build/src/database/record/index.d.ts +23 -13
  34. package/build/src/database/record/index.d.ts.map +1 -1
  35. package/build/src/database/record/index.js +14 -9
  36. package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts.map +1 -1
  37. package/build/src/environment-handlers/node/cli/commands/generate/base-models.js +2 -15
  38. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts +89 -32
  39. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
  40. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +123 -72
  41. package/build/src/frontend-model-controller.d.ts.map +1 -1
  42. package/build/src/frontend-model-controller.js +8 -1
  43. package/build/src/frontend-model-resource/base-resource.d.ts +203 -64
  44. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  45. package/build/src/frontend-model-resource/base-resource.js +237 -54
  46. package/build/src/frontend-models/base.d.ts +173 -110
  47. package/build/src/frontend-models/base.d.ts.map +1 -1
  48. package/build/src/frontend-models/base.js +218 -102
  49. package/build/src/frontend-models/preloader.d.ts.map +1 -1
  50. package/build/src/frontend-models/preloader.js +4 -3
  51. package/build/src/testing/browser-frontend-model-event-hook-scenarios.js +2 -2
  52. package/build/src/testing/expect.d.ts +6 -0
  53. package/build/src/testing/expect.d.ts.map +1 -1
  54. package/build/src/testing/expect.js +9 -1
  55. package/build/testing/browser-frontend-model-event-hook-scenarios.js +1 -1
  56. package/build/testing/expect.js +9 -0
  57. package/package.json +1 -1
  58. package/src/configuration-types.js +2 -0
  59. package/src/database/pool/async-tracked-multi-connection.js +123 -8
  60. package/src/database/pool/base.js +14 -1
  61. package/src/database/record/index.js +13 -8
  62. package/src/environment-handlers/node/cli/commands/generate/base-models.js +1 -16
  63. package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +125 -73
  64. package/src/frontend-model-controller.js +9 -0
  65. package/src/frontend-model-resource/base-resource.js +266 -53
  66. package/src/frontend-models/base.js +241 -97
  67. package/src/frontend-models/preloader.js +3 -2
  68. package/src/testing/browser-frontend-model-event-hook-scenarios.js +1 -1
  69. package/src/testing/expect.js +9 -0
@@ -4,10 +4,29 @@ import AuthorizationBaseResource from "../authorization/base-resource.js"
4
4
  import * as inflection from "inflection"
5
5
  import isPlainObject from "../utils/plain-object.js"
6
6
 
7
+ /**
8
+ * Built-in frontend-model resource action.
9
+ * @typedef {"index" | "find" | "create" | "update" | "destroy" | "attach" | "download" | "url"} FrontendModelResourceAction
10
+ */
11
+
12
+ /**
13
+ * Frontend-model controller methods used by resources.
14
+ * @typedef {import("../controller.js").default & {
15
+ * currentAbility: () => import("../authorization/ability.js").default | undefined,
16
+ * frontendModelAbilityAction: (action: FrontendModelResourceAction) => string,
17
+ * frontendModelAuthorizedQuery: (action: FrontendModelResourceAction) => import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>,
18
+ * frontendModelIndexQuery: () => import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>,
19
+ * frontendModelParams: () => import("../configuration-types.js").VelociousParams,
20
+ * frontendModelPreload: () => import("../database/query/index.js").NestedPreloadRecord | null,
21
+ * frontendModelResourceConfigurationForModelClass: (modelClass: typeof import("../database/record/index.js").default) => FrontendModelResolvedResourceConfiguration | null,
22
+ * serializeFrontendModel: (model: import("../database/record/index.js").default) => Promise<Record<string, object | string | number | boolean | null>>
23
+ * }} FrontendModelResourceController
24
+ */
25
+
7
26
  /**
8
27
  * FrontendModelResourceControllerArgs type.
9
28
  * @typedef {object} FrontendModelResourceControllerArgs
10
- * @property {import("../controller.js").default} controller - Frontend-model controller instance.
29
+ * @property {FrontendModelResourceController} controller - Frontend-model controller instance.
11
30
  * @property {typeof import("../database/record/index.js").default} modelClass - Backing model class.
12
31
  * @property {string} modelName - Model name.
13
32
  * @property {import("../configuration-types.js").VelociousParams} params - Request params.
@@ -35,6 +54,31 @@ import isPlainObject from "../utils/plain-object.js"
35
54
  * @property {import("../configuration-types.js").NormalizedFrontendModelResourceConfiguration} resourceConfiguration - Normalized resource configuration.
36
55
  */
37
56
 
57
+ /**
58
+ * Transport-safe value accepted in frontend-model resource mutation payloads.
59
+ * Nested object/array values are intentionally opaque because TypeScript rejects
60
+ * recursive JSDoc typedefs for this transport payload contract.
61
+ * @typedef {import("../frontend-models/base.js").FrontendModelTransportValue | import("../database/record/index.js").default | Record<string, unknown> | Array<unknown>} FrontendModelResourcePayloadValue
62
+ */
63
+
64
+ /**
65
+ * Attribute payload accepted by frontend-model resource mutations.
66
+ * @typedef {Record<string, FrontendModelResourcePayloadValue>} FrontendModelResourceAttributePayload
67
+ */
68
+
69
+ /**
70
+ * Options passed while saving frontend-model resource mutations.
71
+ * @typedef {object} FrontendModelResourceSaveOptions
72
+ * @property {FrontendModelResourceAttributePayload | null} [attachments] - Uploaded attachment attributes.
73
+ * @property {FrontendModelResourceController | null} [controller] - Controller handling the mutation.
74
+ * @property {FrontendModelResourceAttributePayload | null} [nestedAttributes] - Nested attributes payload.
75
+ */
76
+
77
+ /**
78
+ * Normalized nested attributes entry.
79
+ * @typedef {FrontendModelResourceAttributePayload & {id?: string | number, _destroy?: boolean, attributes?: FrontendModelResourceAttributePayload, attachments?: FrontendModelResourceAttributePayload, nestedAttributes?: FrontendModelResourceAttributePayload}} FrontendModelResourceNestedEntry
80
+ */
81
+
38
82
  /**
39
83
  * Base class for backend frontend-model resources.
40
84
  * @template {typeof import("../database/record/index.js").default} [TModelClass=typeof import("../database/record/index.js").default]
@@ -108,17 +152,10 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
108
152
 
109
153
  /**
110
154
  * Runs typed controller instance.
111
- * @returns {import("../controller.js").default & {
112
- * frontendModelAuthorizedQuery: (action: "index" | "find" | "create" | "update" | "destroy" | "attach" | "download" | "url") => import("../database/query/model-class-query.js").default<TModelClass>,
113
- * frontendModelAbilityAction: (action: string) => string,
114
- * currentAbility: () => import("../authorization/ability.js").default | undefined,
115
- * frontendModelIndexQuery: () => import("../database/query/model-class-query.js").default<TModelClass>,
116
- * frontendModelPreload: () => import("../database/query/index.js").NestedPreloadRecord | null,
117
- * serializeFrontendModel: (model: import("../database/record/index.js").default) => Promise<Record<string, unknown>>
118
- * }} - Controller instance with frontend-model helpers.
155
+ * @returns {FrontendModelResourceController} - Controller instance with frontend-model helpers.
119
156
  */
120
157
  typedControllerInstance() {
121
- return /** Narrows the runtime value to the documented type. @type {?} */ (this.controller)
158
+ return /** Narrows the runtime value to the documented type. @type {FrontendModelResourceController} */ (this.controller)
122
159
  }
123
160
 
124
161
  /**
@@ -244,6 +281,118 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
244
281
  return []
245
282
  }
246
283
 
284
+ /**
285
+ * Normalizes create attributes before permission filtering and saving.
286
+ * @param {FrontendModelResourceAttributePayload} attributes - Incoming create attributes.
287
+ * @param {FrontendModelResourceSaveOptions} options - Save options.
288
+ * @returns {FrontendModelResourceAttributePayload | Promise<FrontendModelResourceAttributePayload>} - Normalized attributes.
289
+ */
290
+ normalizeCreateAttributes(attributes, options) {
291
+ void options
292
+
293
+ return attributes
294
+ }
295
+
296
+ /**
297
+ * Normalizes update attributes before permission filtering and saving.
298
+ * @param {import("../database/record/index.js").default} model - Existing model.
299
+ * @param {FrontendModelResourceAttributePayload} attributes - Incoming update attributes.
300
+ * @param {FrontendModelResourceSaveOptions} options - Save options.
301
+ * @returns {FrontendModelResourceAttributePayload | Promise<FrontendModelResourceAttributePayload>} - Normalized attributes.
302
+ */
303
+ normalizeUpdateAttributes(model, attributes, options) {
304
+ void model
305
+ void options
306
+
307
+ return attributes
308
+ }
309
+
310
+ /**
311
+ * Runs before create.
312
+ * @param {import("../database/record/index.js").default} model - New model before assignment/save.
313
+ * @param {FrontendModelResourceAttributePayload} attributes - Normalized create attributes.
314
+ * @param {FrontendModelResourceSaveOptions} options - Save options.
315
+ * @returns {void | Promise<void>} - Resolves when the hook finishes.
316
+ */
317
+ beforeCreate(model, attributes, options) {
318
+ void model
319
+ void attributes
320
+ void options
321
+ }
322
+
323
+ /**
324
+ * Runs after create.
325
+ * @param {import("../database/record/index.js").default} model - Created model.
326
+ * @param {FrontendModelResourceAttributePayload} attributes - Normalized create attributes.
327
+ * @param {FrontendModelResourceSaveOptions} options - Save options.
328
+ * @returns {void | Promise<void>} - Resolves when the hook finishes.
329
+ */
330
+ afterCreate(model, attributes, options) {
331
+ void model
332
+ void attributes
333
+ void options
334
+ }
335
+
336
+ /**
337
+ * Runs before update.
338
+ * @param {import("../database/record/index.js").default} model - Existing model before assignment/save.
339
+ * @param {FrontendModelResourceAttributePayload} attributes - Normalized update attributes.
340
+ * @param {FrontendModelResourceSaveOptions} options - Save options.
341
+ * @returns {void | Promise<void>} - Resolves when the hook finishes.
342
+ */
343
+ beforeUpdate(model, attributes, options) {
344
+ void model
345
+ void attributes
346
+ void options
347
+ }
348
+
349
+ /**
350
+ * Runs after update.
351
+ * @param {import("../database/record/index.js").default} model - Updated model.
352
+ * @param {FrontendModelResourceAttributePayload} attributes - Normalized update attributes.
353
+ * @param {FrontendModelResourceSaveOptions} options - Save options.
354
+ * @returns {void | Promise<void>} - Resolves when the hook finishes.
355
+ */
356
+ afterUpdate(model, attributes, options) {
357
+ void model
358
+ void attributes
359
+ void options
360
+ }
361
+
362
+ /**
363
+ * Runs before destroy.
364
+ * @param {import("../database/record/index.js").default} model - Model before destroy.
365
+ * @returns {void | Promise<void>} - Resolves when the hook finishes.
366
+ */
367
+ beforeDestroy(model) {
368
+ void model
369
+ }
370
+
371
+ /**
372
+ * Runs after destroy.
373
+ * @param {import("../database/record/index.js").default} model - Destroyed model.
374
+ * @returns {void | Promise<void>} - Resolves when the hook finishes.
375
+ */
376
+ afterDestroy(model) {
377
+ void model
378
+ }
379
+
380
+ /**
381
+ * Wraps create/update/destroy resource mutations.
382
+ * @template Result
383
+ * @param {object} args - Transaction args.
384
+ * @param {"create" | "update" | "destroy"} args.action - Mutation action.
385
+ * @param {import("../database/record/index.js").default} args.model - Mutated model.
386
+ * @param {() => Promise<Result>} args.callback - Mutation callback.
387
+ * @returns {Promise<Result>} - Callback result.
388
+ */
389
+ async runMutationTransaction({action, model, callback}) {
390
+ void action
391
+ void model
392
+
393
+ return await callback()
394
+ }
395
+
247
396
  /**
248
397
  * Runs primary key.
249
398
  * @returns {string} - Primary key.
@@ -252,11 +401,12 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
252
401
 
253
402
  /**
254
403
  * Runs authorized query.
255
- * @param {"index" | "find" | "create" | "update" | "destroy" | "attach" | "download" | "url"} action - Ability action.
404
+ * @param {FrontendModelResourceAction} action - Ability action.
256
405
  * @returns {import("../database/query/model-class-query.js").default<TModelClass>} - Authorized query.
257
406
  */
258
407
  authorizedQuery(action) {
259
- return this.typedControllerInstance().frontendModelAuthorizedQuery(action)
408
+ // Narrows the controller query to this resource's model class.
409
+ return /** @type {import("../database/query/model-class-query.js").default<TModelClass>} */ (this.typedControllerInstance().frontendModelAuthorizedQuery(action))
260
410
  }
261
411
 
262
412
 
@@ -329,18 +479,30 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
329
479
 
330
480
  /**
331
481
  * Runs create.
332
- * @param {Record<string, ?>} attributes - Create attributes.
333
- * @param {{attachments?: Record<string, ?> | null, controller?: ?, nestedAttributes?: Record<string, ?> | null}} [options] - Save options.
482
+ * @param {FrontendModelResourceAttributePayload} attributes - Create attributes.
483
+ * @param {FrontendModelResourceSaveOptions} [options] - Save options.
334
484
  * @returns {Promise<import("../database/record/index.js").default>} - Created model.
335
485
  */
336
486
  async create(attributes, options = {}) {
337
- const attachmentSplit = this._extractAttachmentAttributes(attributes, options.attachments ?? null)
338
- const permit = parsePermittedParams(this.permittedParams({action: "create", ability: this.ability, locals: this.locals, params: attributes}))
487
+ const normalizedAttributes = await this.normalizeCreateAttributes(attributes, options)
488
+ const attachmentSplit = this._extractAttachmentAttributes(normalizedAttributes, options.attachments ?? null)
489
+ const permit = parsePermittedParams(this.permittedParams({action: "create", ability: this.ability, locals: this.locals, params: normalizedAttributes}))
339
490
  const filtered = filterWritableFrontendModelAttributes(this.modelClass().prototype, attachmentSplit.attributes, this, permit.attributes)
340
491
  const ModelClass = this.modelClass()
341
492
  const model = new ModelClass()
342
493
 
343
- return await this._saveWithNestedAttributes({filtered, model, options: {...options, attachments: attachmentSplit.attachments}, permit})
494
+ return await this.runMutationTransaction({
495
+ action: "create",
496
+ model,
497
+ callback: async () => {
498
+ await this.beforeCreate(model, normalizedAttributes, options)
499
+ const savedModel = await this._saveWithNestedAttributes({filtered, model, options: {...options, attachments: attachmentSplit.attachments}, permit})
500
+
501
+ await this.afterCreate(savedModel, normalizedAttributes, options)
502
+
503
+ return savedModel
504
+ }
505
+ })
344
506
  }
345
507
 
346
508
  /**
@@ -355,21 +517,33 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
355
517
  /**
356
518
  * Runs update.
357
519
  * @param {import("../database/record/index.js").default} model - Existing model.
358
- * @param {Record<string, ?>} attributes - Update attributes.
359
- * @param {{attachments?: Record<string, ?> | null, controller?: ?, nestedAttributes?: Record<string, ?> | null}} [options] - Save options.
520
+ * @param {FrontendModelResourceAttributePayload} attributes - Update attributes.
521
+ * @param {FrontendModelResourceSaveOptions} [options] - Save options.
360
522
  * @returns {Promise<import("../database/record/index.js").default>} - Updated model.
361
523
  */
362
524
  async update(model, attributes, options = {}) {
363
- const attachmentSplit = this._extractAttachmentAttributes(attributes, options.attachments ?? null)
364
- const permit = parsePermittedParams(this.permittedParams({action: "update", ability: this.ability, locals: this.locals, params: attributes}))
525
+ const normalizedAttributes = await this.normalizeUpdateAttributes(model, attributes, options)
526
+ const attachmentSplit = this._extractAttachmentAttributes(normalizedAttributes, options.attachments ?? null)
527
+ const permit = parsePermittedParams(this.permittedParams({action: "update", ability: this.ability, locals: this.locals, params: normalizedAttributes}))
365
528
  const filtered = filterWritableFrontendModelAttributes(model, attachmentSplit.attributes, this, permit.attributes)
366
529
 
367
- return await this._saveWithNestedAttributes({filtered, model, options: {...options, attachments: attachmentSplit.attachments}, permit})
530
+ return await this.runMutationTransaction({
531
+ action: "update",
532
+ model,
533
+ callback: async () => {
534
+ await this.beforeUpdate(model, normalizedAttributes, options)
535
+ const savedModel = await this._saveWithNestedAttributes({filtered, model, options: {...options, attachments: attachmentSplit.attachments}, permit})
536
+
537
+ await this.afterUpdate(savedModel, normalizedAttributes, options)
538
+
539
+ return savedModel
540
+ }
541
+ })
368
542
  }
369
543
 
370
544
  /**
371
545
  * Saves a model and applies nested attributes in one transaction.
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.
546
+ * @param {{filtered: Record<string, ?>, model: import("../database/record/index.js").default, options: FrontendModelResourceSaveOptions, permit: {attributes: string[], nested: Record<string, ?>}}} args - Save arguments.
373
547
  * @returns {Promise<import("../database/record/index.js").default>} - Saved model.
374
548
  */
375
549
  async _saveWithNestedAttributes({filtered, model, options, permit}) {
@@ -516,7 +690,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
516
690
  * Sets a translated attribute on a model via the translations relationship.
517
691
  * @param {import("../database/record/index.js").default} model - Model instance.
518
692
  * @param {string} name - Attribute name.
519
- * @param {?} value - Attribute value.
693
+ * @param {FrontendModelResourcePayloadValue} value - Attribute value.
520
694
  * @returns {Promise<void>}
521
695
  */
522
696
  async _setTranslatedAttributeOnModel(model, name, value) {
@@ -569,7 +743,15 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
569
743
  * @returns {Promise<void>} - No return value.
570
744
  */
571
745
  async destroy(model) {
572
- await model.destroy()
746
+ await this.runMutationTransaction({
747
+ action: "destroy",
748
+ model,
749
+ callback: async () => {
750
+ await this.beforeDestroy(model)
751
+ await model.destroy()
752
+ await this.afterDestroy(model)
753
+ }
754
+ })
573
755
  }
574
756
 
575
757
  /**
@@ -589,10 +771,10 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
589
771
  * @param {object} args - Nested relationship inputs.
590
772
  * @param {import("../database/record/index.js").default} args.parent - Parent model instance.
591
773
  * @param {string} args.relationshipName - Relationship receiving nested attributes.
592
- * @param {?} args.rawEntries - Raw nested entries from the request payload.
774
+ * @param {FrontendModelResourcePayloadValue} args.rawEntries - Raw nested entries from the request payload.
593
775
  * @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.
776
+ * @param {FrontendModelResourceController | null | undefined} args.controller - Controller instance for child resource lookup.
777
+ * @returns {{ability: import("../authorization/ability.js").default | undefined, childResource: FrontendModelBaseResource, childResourceConfig: FrontendModelResolvedResourceConfiguration, childWritableAttributes: string[], destroyPermitted: boolean, entries: Array<FrontendModelResourceNestedEntry>, relationship: import("../database/record/relationships/base.js").default, targetModelClass: typeof import("../database/record/index.js").default}} Nested relationship context.
596
778
  */
597
779
  _nestedRelationshipContext({parent, relationshipName, rawEntries, childPermit, controller}) {
598
780
  const parentModelClass = parent.getModelClass()
@@ -631,7 +813,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
631
813
 
632
814
  const childResource = new childResourceConfig.resourceClass({
633
815
  ability: this.ability,
634
- controller,
816
+ controller: controller || undefined,
635
817
  context: this.context || {},
636
818
  locals: this.locals || {},
637
819
  modelClass: targetModelClass,
@@ -663,10 +845,10 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
663
845
  /**
664
846
  * Normalizes nested entries for collection and singular relationships.
665
847
  * @param {object} args - Nested entries inputs.
666
- * @param {?} args.rawEntries - Raw nested entries value.
848
+ * @param {FrontendModelResourcePayloadValue} args.rawEntries - Raw nested entries value.
667
849
  * @param {string} args.relationshipName - Relationship name.
668
850
  * @param {string} args.relationshipType - Relationship type.
669
- * @returns {Array<Record<string, ?>>} Normalized nested entry objects.
851
+ * @returns {Array<FrontendModelResourceNestedEntry>} Normalized nested entry objects.
670
852
  */
671
853
  _nestedRelationshipEntries({rawEntries, relationshipName, relationshipType}) {
672
854
  if (relationshipType === "hasMany") {
@@ -677,7 +859,8 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
677
859
  return rawEntries.map((entry) => {
678
860
  if (!isPlainObject(entry)) throw new Error(`nestedAttributes['${relationshipName}'] entries must be objects.`)
679
861
 
680
- return entry
862
+ // Narrows the plain-object payload to a normalized nested-entry object.
863
+ return /** @type {FrontendModelResourceNestedEntry} */ (entry)
681
864
  })
682
865
  }
683
866
 
@@ -686,14 +869,16 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
686
869
  return rawEntries.map((entry) => {
687
870
  if (!isPlainObject(entry)) throw new Error(`nestedAttributes['${relationshipName}'] entries must be objects.`)
688
871
 
689
- return entry
872
+ // Narrows the plain-object payload to a normalized nested-entry object.
873
+ return /** @type {FrontendModelResourceNestedEntry} */ (entry)
690
874
  })
691
875
  }
692
876
  if (!isPlainObject(rawEntries)) {
693
877
  throw new Error(`Expected object for nestedAttributes['${relationshipName}'] but got: ${typeof rawEntries}`)
694
878
  }
695
879
 
696
- return [rawEntries]
880
+ // Narrows the plain-object payload to a normalized nested-entry object.
881
+ return [/** @type {FrontendModelResourceNestedEntry} */ (rawEntries)]
697
882
  }
698
883
 
699
884
  /**
@@ -702,25 +887,38 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
702
887
  * fields (`{name, file, commentsAttributes}`).
703
888
  * @param {object} args - Normalization inputs.
704
889
  * @param {{attributes: string[], nested: Record<string, ?>}} args.childPermit - Parsed child permit spec.
705
- * @param {Record<string, ?>} args.entry - Raw nested entry.
890
+ * @param {FrontendModelResourceNestedEntry} args.entry - Raw nested entry.
706
891
  * @param {string} args.relationshipName - Relationship name for error messages.
707
892
  * @param {typeof import("../database/record/index.js").default} args.targetModelClass - Child model class.
708
- * @returns {Record<string, ?>} Normalized nested entry.
893
+ * @returns {FrontendModelResourceNestedEntry} Normalized nested entry.
709
894
  */
710
895
  _normalizeNestedRelationshipEntry({childPermit, entry, relationshipName, targetModelClass}) {
711
- /** @type {Record<string, ?>} */
896
+ /** @type {FrontendModelResourceAttributePayload} */
712
897
  const attributes = {}
713
- /** @type {Record<string, ?>} */
898
+ /** @type {FrontendModelResourceAttributePayload} */
714
899
  const attachments = {}
715
- /** @type {Record<string, ?>} */
900
+ /** @type {FrontendModelResourceAttributePayload} */
716
901
  const nestedAttributes = {}
717
- /** @type {Record<string, ?>} */
902
+ /** @type {FrontendModelResourceNestedEntry} */
718
903
  const normalized = {}
719
904
  const attachmentDefinitions = targetModelClass.getAttachmentsMap?.() || {}
720
905
 
721
906
  for (const [attributeName, value] of Object.entries(entry)) {
722
- if (attributeName === "id" || attributeName === "_destroy") {
723
- normalized[attributeName] = value
907
+ if (attributeName === "id") {
908
+ if (typeof value !== "string" && typeof value !== "number") {
909
+ throw new Error(`nestedAttributes['${relationshipName}'] entry id must be a string or number.`)
910
+ }
911
+
912
+ normalized.id = value
913
+ continue
914
+ }
915
+
916
+ if (attributeName === "_destroy") {
917
+ if (typeof value !== "boolean") {
918
+ throw new Error(`nestedAttributes['${relationshipName}'] entry _destroy must be a boolean.`)
919
+ }
920
+
921
+ normalized._destroy = value
724
922
  continue
725
923
  }
726
924
 
@@ -771,8 +969,8 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
771
969
  /**
772
970
  * Applies belongs-to nested attributes before the parent save so the parent FK can be set.
773
971
  * @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.
972
+ * @param {FrontendModelResourceAttributePayload} nestedAttributes - Nested-attribute payload keyed by relationship name.
973
+ * @param {FrontendModelResourceController | null | undefined} controller - Controller instance for resource resolution and authorization.
776
974
  * @param {{attributes: string[], nested: Record<string, ?>} | null} [parentPermit] - Parsed parent permit spec.
777
975
  * @returns {Promise<void>}
778
976
  */
@@ -802,13 +1000,15 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
802
1000
  if (!context.destroyPermitted) {
803
1001
  throw new Error(`nestedAttributes['${relationshipName}'] entry requested _destroy but "_destroy" is not in the permit for this relationship.`)
804
1002
  }
805
- if (!entry.id) throw new Error(`nestedAttributes['${relationshipName}'] _destroy entry is missing an id.`)
1003
+ const id = entry.id
1004
+
1005
+ if (id == undefined) throw new Error(`nestedAttributes['${relationshipName}'] _destroy entry is missing an id.`)
806
1006
 
807
1007
  const existing = await this._findNestedRecord({
808
1008
  ability: context.ability,
809
1009
  action: "destroy",
810
1010
  childResourceConfiguration: context.childResourceConfig.resourceConfiguration,
811
- id: entry.id,
1011
+ id,
812
1012
  relationshipName,
813
1013
  targetModelClass: context.targetModelClass
814
1014
  })
@@ -818,12 +1018,13 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
818
1018
  continue
819
1019
  }
820
1020
 
821
- const child = entry.id
1021
+ const id = entry.id
1022
+ const child = id != undefined
822
1023
  ? await this._findNestedRecord({
823
1024
  ability: context.ability,
824
1025
  action: "update",
825
1026
  childResourceConfiguration: context.childResourceConfig.resourceConfiguration,
826
- id: entry.id,
1027
+ id,
827
1028
  relationshipName,
828
1029
  targetModelClass: context.targetModelClass
829
1030
  })
@@ -837,7 +1038,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
837
1038
  await context.childResource._applyBelongsToNestedAttributes(child, entry.nestedAttributes || {}, controller, childPermit)
838
1039
  await child.save()
839
1040
 
840
- if (!entry.id) {
1041
+ if (id == undefined) {
841
1042
  await this._authorizeCreatedChild({
842
1043
  ability: context.ability,
843
1044
  child,
@@ -869,8 +1070,8 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
869
1070
  * (allowDestroy, limit, rejectIf) come from the MODEL's
870
1071
  * `acceptedNestedAttributesFor(name)` declaration.
871
1072
  * @param {import("../database/record/index.js").default} parent - Parent model instance.
872
- * @param {Record<string, ?>} nestedAttributes - Nested-attribute payload keyed by relationship name.
873
- * @param {?} controller - Controller instance for resource resolution and authorization.
1073
+ * @param {FrontendModelResourceAttributePayload} nestedAttributes - Nested-attribute payload keyed by relationship name.
1074
+ * @param {FrontendModelResourceController | null | undefined} controller - Controller instance for resource resolution and authorization.
874
1075
  * @param {{attributes: string[], nested: Record<string, ?>} | null} [parentPermit] - Parsed parent permit spec.
875
1076
  * @returns {Promise<void>}
876
1077
  */
@@ -922,11 +1123,17 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
922
1123
  }
923
1124
 
924
1125
  for (const entry of destroyEntries) {
1126
+ const id = entry.id
1127
+
1128
+ if (id == undefined) {
1129
+ throw new Error(`nestedAttributes['${relationshipName}'] _destroy entry is missing an id.`)
1130
+ }
1131
+
925
1132
  const existing = await this._findScopedChild({
926
1133
  ability: context.ability,
927
1134
  action: "destroy",
928
1135
  childResourceConfiguration: context.childResourceConfig.resourceConfiguration,
929
- id: entry.id,
1136
+ id,
930
1137
  parent,
931
1138
  parentLinkAttributes,
932
1139
  relationshipName,
@@ -937,11 +1144,17 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
937
1144
  }
938
1145
 
939
1146
  for (const entry of updateEntries) {
1147
+ const id = entry.id
1148
+
1149
+ if (id == undefined) {
1150
+ throw new Error(`nestedAttributes['${relationshipName}'] update entry is missing an id.`)
1151
+ }
1152
+
940
1153
  const existing = await this._findScopedChild({
941
1154
  ability: context.ability,
942
1155
  action: "update",
943
1156
  childResourceConfiguration: context.childResourceConfig.resourceConfiguration,
944
- id: entry.id,
1157
+ id,
945
1158
  parent,
946
1159
  parentLinkAttributes,
947
1160
  relationshipName,