velocious 1.0.473 → 1.0.474

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 (32) hide show
  1. package/build/authorization/base-resource.js +99 -0
  2. package/build/configuration-types.js +3 -2
  3. package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +1 -1
  4. package/build/frontend-model-controller.js +12 -12
  5. package/build/frontend-model-resource/base-resource.js +254 -46
  6. package/build/frontend-model-resource/velocious-attachment-resource.js +0 -3
  7. package/build/frontend-models/resource-definition.js +96 -21
  8. package/build/src/authorization/base-resource.d.ts +41 -0
  9. package/build/src/authorization/base-resource.d.ts.map +1 -1
  10. package/build/src/authorization/base-resource.js +88 -1
  11. package/build/src/configuration-types.d.ts +15 -3
  12. package/build/src/configuration-types.d.ts.map +1 -1
  13. package/build/src/configuration-types.js +4 -3
  14. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +2 -2
  15. package/build/src/frontend-model-controller.js +13 -13
  16. package/build/src/frontend-model-resource/base-resource.d.ts +67 -0
  17. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  18. package/build/src/frontend-model-resource/base-resource.js +242 -54
  19. package/build/src/frontend-model-resource/velocious-attachment-resource.d.ts +0 -2
  20. package/build/src/frontend-model-resource/velocious-attachment-resource.d.ts.map +1 -1
  21. package/build/src/frontend-model-resource/velocious-attachment-resource.js +1 -3
  22. package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
  23. package/build/src/frontend-models/resource-definition.js +83 -25
  24. package/package.json +1 -1
  25. package/scripts/tensorbuzz-retry +21 -0
  26. package/src/authorization/base-resource.js +99 -0
  27. package/src/configuration-types.js +3 -2
  28. package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +1 -1
  29. package/src/frontend-model-controller.js +12 -12
  30. package/src/frontend-model-resource/base-resource.js +254 -46
  31. package/src/frontend-model-resource/velocious-attachment-resource.js +0 -3
  32. package/src/frontend-models/resource-definition.js +96 -21
@@ -115,6 +115,105 @@ export default class AuthorizationBaseResource {
115
115
  return this.context.currentUser
116
116
  }
117
117
 
118
+ /**
119
+ * Runs current device.
120
+ * @returns {unknown} - Current device from context.
121
+ */
122
+ currentDevice() {
123
+ return this.context.currentDevice
124
+ }
125
+
126
+ /**
127
+ * Runs offline grant.
128
+ * @returns {unknown} - Offline grant from context.
129
+ */
130
+ offlineGrant() {
131
+ return this.context.offlineGrant
132
+ }
133
+
134
+ /**
135
+ * Runs now.
136
+ * @returns {Date} - Current time from context or the system clock.
137
+ */
138
+ now() {
139
+ if (typeof this.context.now === "function") return this.context.now()
140
+ if (this.context.now instanceof Date) return this.context.now
141
+
142
+ return new Date()
143
+ }
144
+
145
+ /**
146
+ * Runs resource runtime.
147
+ * @returns {"backend" | "frontend" | "offline"} - Resource runtime context.
148
+ */
149
+ resourceRuntime() {
150
+ if (this.context.resourceRuntime === "frontend") return "frontend"
151
+ if (this.context.resourceRuntime === "offline") return "offline"
152
+
153
+ return "backend"
154
+ }
155
+
156
+ /**
157
+ * Runs is backend.
158
+ * @returns {boolean} - Whether the resource is running in the backend runtime.
159
+ */
160
+ isBackend() {
161
+ return this.resourceRuntime() === "backend"
162
+ }
163
+
164
+ /**
165
+ * Runs is frontend.
166
+ * @returns {boolean} - Whether the resource is running in the frontend runtime.
167
+ */
168
+ isFrontend() {
169
+ return this.resourceRuntime() === "frontend"
170
+ }
171
+
172
+ /**
173
+ * Runs is offline.
174
+ * @returns {boolean} - Whether the resource is running with offline context.
175
+ */
176
+ isOffline() {
177
+ return this.resourceRuntime() === "offline" || this.context.offlineGrant !== undefined
178
+ }
179
+
180
+ /**
181
+ * Resolves a model class from the portable resource context.
182
+ * @param {string} name - Model name.
183
+ * @returns {unknown} - Model class from registry.
184
+ */
185
+ model(name) {
186
+ const registry = this.context.modelRegistry
187
+
188
+ if (registry && typeof registry === "object") {
189
+ if ("model" in registry && typeof registry.model === "function") {
190
+ const modelClass = registry.model(name)
191
+
192
+ if (modelClass) return modelClass
193
+ }
194
+
195
+ if (name in registry) return /** @type {Record<string, unknown>} */ (registry)[name]
196
+ }
197
+
198
+ const contextModel = this.context.model
199
+
200
+ if (typeof contextModel === "function") {
201
+ const modelClass = contextModel(name)
202
+
203
+ if (modelClass) return modelClass
204
+ }
205
+
206
+ const configuration = this.context.configuration
207
+
208
+ if (configuration) {
209
+ const modelClasses = configuration.getModelClasses()
210
+
211
+ if (name in modelClasses) return modelClasses[name]
212
+ }
213
+
214
+ throw new Error(`${this.constructor.name} could not resolve model '${name}' from the resource context model registry.`)
215
+ }
216
+
118
217
  /**
119
218
  * Runs request.
120
219
  * @returns {import("../http-server/client/request.js").default | import("../http-server/client/websocket-request.js").default | undefined} - Request from context.
@@ -240,7 +240,7 @@
240
240
  */
241
241
 
242
242
  /**
243
- * @typedef {Record<string, unknown> & {configuration?: import("./configuration.js").default, currentUser?: unknown, params?: VelociousParams, request?: import("./http-server/client/request.js").default | import("./http-server/client/websocket-request.js").default}} VelociousLooseObject
243
+ * @typedef {Record<string, unknown> & {configuration?: import("./configuration.js").default, currentDevice?: unknown, currentUser?: unknown, modelRegistry?: Record<string, unknown> | {model: (name: string) => unknown}, now?: Date | (() => Date), offlineGrant?: unknown, params?: VelociousParams, request?: import("./http-server/client/request.js").default | import("./http-server/client/websocket-request.js").default, resourceRuntime?: "backend" | "frontend" | "offline"}} VelociousLooseObject
244
244
  */
245
245
 
246
246
  /**
@@ -309,13 +309,14 @@
309
309
  /**
310
310
  * @typedef {object} FrontendModelResourceConfiguration
311
311
  * @property {Array<string | FrontendModelAttributeConfiguration> | Record<string, FrontendModelAttributeConfiguration | import("./database/drivers/base-column.js").default | boolean>} attributes - Attributes to expose on the frontend model.
312
- * @property {string[]} [abilities] - Ability action list (camelCase action names). Defaults to `["read"]` for `find` and `index` when omitted.
312
+ * @property {string[]} [abilities] - Additional camelCase ability action names to expose for per-record `record.can(action)` reads. Base CRUD actions (`read`, `create`, `update`, `destroy`) are always included and must not be listed here.
313
313
  * @property {Record<string, FrontendModelAttachmentConfiguration>} [attachments] - Attachment helpers keyed by attachment name.
314
314
  * @property {string[]} [commands] - Legacy built-in command names (`index`, `find`, `create`, `update`, `destroy`, `attach`, `download`, `url`).
315
315
  * @property {Array<FrontendModelResourceCustomCommand>} [collectionCommands] - Custom collection commands. Each entry is a camelCase method name, or a `{name, args?, returnType?}` object declaring typed arguments and/or a response type. The runtime derives the kebab-case command slug from the name.
316
316
  * @property {Array<FrontendModelResourceCustomCommand>} [memberCommands] - Custom member commands. Each entry is a camelCase method name, or a `{name, args?, returnType?}` object declaring typed arguments and/or a response type. The runtime derives the kebab-case command slug from the name.
317
317
  * @property {string[]} [builtInCollectionCommands] - Built-in collection command names (`index`, `create`).
318
318
  * @property {string[]} [builtInMemberCommands] - Built-in member command names (`find`, `update`, `destroy`, `attach`, `download`, `url`).
319
+ * @property {string} [modelName] - Frontend model name override.
319
320
  * @property {string[]} [relationships] - Relationship names to expose in frontend models. Type and target model are inferred from the backend model's registered relationships.
320
321
  * @property {string} [primaryKey] - Primary key attribute name.
321
322
  * @property {FrontendModelResourceServerConfiguration} [server] - Optional legacy backend behavior overrides for built-in frontend actions.
@@ -1439,7 +1439,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1439
1439
  */
1440
1440
  frontendAttributeIsTranslated({attributeName, modelClass, resourceClass}) {
1441
1441
  if (resourceClass) {
1442
- const translatedAttributes = resourceClass.translatedAttributes
1442
+ const translatedAttributes = resourceClass.translatedAttributesConfig()
1443
1443
 
1444
1444
  if (Array.isArray(translatedAttributes) && translatedAttributes.includes(attributeName)) return true
1445
1445
  }
@@ -2430,7 +2430,7 @@ export default class FrontendModelController extends Controller {
2430
2430
 
2431
2431
  const resource = this.frontendModelResourceInstance()
2432
2432
  const resourceClass = /** @type {typeof import("./frontend-model-resource/base-resource.js").default} */ (resource.constructor)
2433
- const translatedSet = new Set(resourceClass.translatedAttributes || [])
2433
+ const translatedSet = new Set(resourceClass.translatedAttributesConfig() || [])
2434
2434
  let needsTranslations = false
2435
2435
 
2436
2436
  for (const attributeName of selectedAttributes) {
@@ -2667,10 +2667,10 @@ export default class FrontendModelController extends Controller {
2667
2667
  * Resource has attribute.
2668
2668
  * @param {string} attributeName - Attribute name.
2669
2669
  */
2670
- const resourceHasAttribute = (attributeName) => {
2670
+ const resourceAttributeMethod = (attributeName) => {
2671
2671
  const methodName = resourceAttributeMethodName(attributeName)
2672
2672
 
2673
- return resourceInstance && typeof /** @type {Record<string, ?>} */ (/** @type {?} */ (resourceInstance))[methodName] === "function"
2673
+ return resourceInstance?.resourceMethod(methodName) || null
2674
2674
  }
2675
2675
 
2676
2676
  /**
@@ -2700,10 +2700,10 @@ export default class FrontendModelController extends Controller {
2700
2700
  */
2701
2701
  const serializedAttributeValue = async (attributeName) => {
2702
2702
  // Check resource instance first (virtual/computed attributes via ${name}Attribute convention)
2703
- if (resourceHasAttribute(attributeName)) {
2704
- const methodName = resourceAttributeMethodName(attributeName)
2703
+ const resourceAttribute = resourceAttributeMethod(attributeName)
2705
2704
 
2706
- return await /** @type {Record<string, Function>} */ (/** @type {?} */ (resourceInstance))[methodName](model)
2705
+ if (resourceAttribute) {
2706
+ return await resourceAttribute.method.call(resourceAttribute.resource, model)
2707
2707
  }
2708
2708
 
2709
2709
  // Fall back to model method
@@ -2722,7 +2722,7 @@ export default class FrontendModelController extends Controller {
2722
2722
  * @param {string} attributeName - Attribute name.
2723
2723
  */
2724
2724
  const attributeExists = (attributeName) => {
2725
- return (attributeName in modelAttributes) || (attributeName in /** @type {Record<string, ?>} */ (model)) || resourceHasAttribute(attributeName)
2725
+ return (attributeName in modelAttributes) || (attributeName in /** @type {Record<string, ?>} */ (model)) || Boolean(resourceAttributeMethod(attributeName))
2726
2726
  }
2727
2727
 
2728
2728
  if (!selectedAttributes) {
@@ -3770,10 +3770,10 @@ export default class FrontendModelController extends Controller {
3770
3770
  return this.frontendModelErrorPayload("Expected frontend-model custom command scope.")
3771
3771
  }
3772
3772
 
3773
- const resource = /** @type {Record<string, ?>} */ (this.frontendModelResourceInstance())
3774
- const commandMethod = resource[methodName]
3773
+ const resource = this.frontendModelResourceInstance()
3774
+ const command = resource.resourceMethod(methodName)
3775
3775
 
3776
- if (typeof commandMethod !== "function") {
3776
+ if (!command) {
3777
3777
  return this.frontendModelErrorPayload(`Missing frontend-model custom command '${methodName}'.`)
3778
3778
  }
3779
3779
 
@@ -3783,7 +3783,7 @@ export default class FrontendModelController extends Controller {
3783
3783
  // unchanged, so existing parameterless methods keep working. The args are untrusted
3784
3784
  // client input typed only by the declared contract, so methods must still validate.
3785
3785
  const commandArguments = this.frontendModelCustomCommandArguments(params)
3786
- const responsePayload = await commandMethod.call(resource, commandArguments)
3786
+ const responsePayload = await command.method.call(command.resource, commandArguments)
3787
3787
 
3788
3788
  if (!responsePayload || typeof responsePayload !== "object") {
3789
3789
  return {status: "success"}
@@ -3792,7 +3792,7 @@ export default class FrontendModelController extends Controller {
3792
3792
  return /** @type {Record<string, ?>} */ (
3793
3793
  await this.autoSerializeFrontendModelsInPayload(
3794
3794
  responsePayload,
3795
- /** @type {{serialize: (model: ?, action: string) => Promise<Record<string, ?>>}} */ (resource),
3795
+ /** @type {{serialize: (model: ?, action: string) => Promise<Record<string, ?>>}} */ (command.resource),
3796
3796
  methodName
3797
3797
  )
3798
3798
  )
@@ -148,6 +148,8 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
148
148
  /** @type {Record<string, ?> | undefined} */
149
149
  static attachments = undefined
150
150
  /** @type {string[] | undefined} */
151
+ static commands = undefined
152
+ /** @type {string[] | undefined} */
151
153
  static collectionCommands = undefined
152
154
  /** @type {string[] | undefined} */
153
155
  static builtInCollectionCommands = undefined
@@ -157,8 +159,16 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
157
159
  static builtInMemberCommands = undefined
158
160
  /** @type {string[] | undefined} */
159
161
  static relationships = undefined
162
+ /** @type {string | undefined} */
163
+ static modelName = undefined
164
+ /** @type {string | undefined} */
165
+ static primaryKey = undefined
166
+ /** @type {import("../configuration-types.js").FrontendModelResourceServerConfiguration | undefined} */
167
+ static server = undefined
160
168
  /** @type {string[] | undefined} */
161
169
  static translatedAttributes = undefined
170
+ /** @type {?} */
171
+ static SharedResource = undefined
162
172
 
163
173
  /**
164
174
  * Runs constructor.
@@ -179,6 +189,149 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
179
189
  this.modelNameValue = "modelName" in args ? args.modelName : this.modelClass().getModelName()
180
190
  this.paramsValue = "params" in args ? args.params : undefined
181
191
  this.resourceConfigurationValue = "resourceConfiguration" in args ? args.resourceConfiguration : defaultResourceConfiguration
192
+ /** @type {FrontendModelBaseResource | null | undefined} */
193
+ this.sharedResourceInstanceValue = undefined
194
+ }
195
+
196
+ /**
197
+ * Returns the configured shared resource class.
198
+ * @returns {?} - Shared resource class.
199
+ */
200
+ static sharedResourceClass() {
201
+ return this.SharedResource
202
+ }
203
+
204
+ /**
205
+ * Reads a static resource config value from the environment resource first,
206
+ * then from the shared resource.
207
+ * @param {"abilities" | "attachments" | "attributes" | "builtInCollectionCommands" | "builtInMemberCommands" | "collectionCommands" | "commands" | "memberCommands" | "modelName" | "primaryKey" | "relationships" | "server" | "translatedAttributes"} name - Static config property name.
208
+ * @returns {?} - Resolved config value.
209
+ */
210
+ static sharedResourceStaticValue(name) {
211
+ if (this[name] !== undefined) return this[name]
212
+
213
+ const SharedResource = /** @type {typeof FrontendModelBaseResource | undefined} */ (this.sharedResourceClass())
214
+
215
+ if (!SharedResource) return undefined
216
+ if (SharedResource[name] !== undefined) return SharedResource[name]
217
+
218
+ return undefined
219
+ }
220
+
221
+ /**
222
+ * Resolves translated attributes from environment and shared resources.
223
+ * @returns {string[] | undefined} - Translated attribute names.
224
+ */
225
+ static translatedAttributesConfig() {
226
+ return /** @type {string[] | undefined} */ (this.sharedResourceStaticValue("translatedAttributes"))
227
+ }
228
+
229
+ /**
230
+ * Builds a resource instance for shared-resource fallback calls.
231
+ * @returns {FrontendModelBaseResource | null} - Shared resource instance when configured.
232
+ */
233
+ sharedResourceInstance() {
234
+ if (this.sharedResourceInstanceValue !== undefined) return this.sharedResourceInstanceValue
235
+
236
+ const ResourceClass = /** @type {typeof FrontendModelBaseResource} */ (this.constructor)
237
+ const SharedResource = /** @type {typeof FrontendModelBaseResource | undefined} */ (ResourceClass.sharedResourceClass())
238
+
239
+ if (!SharedResource) {
240
+ this.sharedResourceInstanceValue = null
241
+ return this.sharedResourceInstanceValue
242
+ }
243
+
244
+ if (SharedResource === ResourceClass) {
245
+ throw new Error(`${ResourceClass.name}.SharedResource cannot point to itself.`)
246
+ }
247
+
248
+ this.sharedResourceInstanceValue = new SharedResource({
249
+ ability: this.ability,
250
+ controller: this.controller,
251
+ context: this.context,
252
+ locals: this.locals,
253
+ modelClass: this.modelClass(),
254
+ modelName: this.modelName(),
255
+ params: this.params(),
256
+ resourceConfiguration: this.resourceConfiguration()
257
+ })
258
+
259
+ return this.sharedResourceInstanceValue
260
+ }
261
+
262
+ /**
263
+ * Calls a shared-resource method only when the shared resource overrides the framework default.
264
+ * @param {string} methodName - Method name to resolve.
265
+ * @param {unknown[]} args - Method args.
266
+ * @returns {{called: boolean, result: unknown}} - Shared method call result.
267
+ */
268
+ callSharedResourceMethod(methodName, args) {
269
+ const sharedResource = this.sharedResourceInstance()
270
+
271
+ if (!sharedResource) return {called: false, result: undefined}
272
+
273
+ const methodOwner = prototypeOwnerForMethod(sharedResource, methodName)
274
+
275
+ if (!methodOwner || methodOwner === FrontendModelBaseResource.prototype || methodOwner === AuthorizationBaseResource.prototype) {
276
+ return {called: false, result: undefined}
277
+ }
278
+
279
+ const method = /** @type {Record<string, (...methodArgs: unknown[]) => unknown>} */ (/** @type {unknown} */ (sharedResource))[methodName]
280
+
281
+ return {called: true, result: method.apply(sharedResource, args)}
282
+ }
283
+
284
+ /**
285
+ * Runs shared method result or a fallback callback.
286
+ * @template Result
287
+ * @param {string} methodName - Shared method name.
288
+ * @param {unknown[]} args - Shared method args.
289
+ * @param {() => Result} fallback - Fallback callback.
290
+ * @returns {Result} - Shared or fallback result.
291
+ */
292
+ sharedResourceMethodOr(methodName, args, fallback) {
293
+ const sharedResult = this.callSharedResourceMethod(methodName, args)
294
+
295
+ if (sharedResult.called) return /** @type {Result} */ (sharedResult.result)
296
+
297
+ return fallback()
298
+ }
299
+
300
+ /**
301
+ * Resolves a method on this resource or its shared fallback.
302
+ * @param {string} methodName - Method name.
303
+ * @returns {{method: (...methodArgs: unknown[]) => unknown, resource: FrontendModelBaseResource} | null} - Resolved method and receiver.
304
+ */
305
+ resourceMethod(methodName) {
306
+ const ownMethod = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (this))[methodName]
307
+
308
+ if (typeof ownMethod === "function") {
309
+ return {
310
+ method: /** @type {(...methodArgs: unknown[]) => unknown} */ (ownMethod),
311
+ resource: this
312
+ }
313
+ }
314
+
315
+ const sharedResource = this.sharedResourceInstance()
316
+
317
+ if (!sharedResource) return null
318
+
319
+ const sharedMethod = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (sharedResource))[methodName]
320
+
321
+ if (typeof sharedMethod !== "function") return null
322
+
323
+ return {
324
+ method: /** @type {(...methodArgs: unknown[]) => unknown} */ (sharedMethod),
325
+ resource: sharedResource
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Runs abilities.
331
+ * @returns {void} - No return value.
332
+ */
333
+ abilities() {
334
+ this.sharedResourceMethodOr("abilities", [], () => undefined)
182
335
  }
183
336
 
184
337
  /**
@@ -194,18 +347,34 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
194
347
  * @returns {import("../configuration-types.js").FrontendModelResourceConfiguration} - Static resource config (raw user input shape; consumers normalize).
195
348
  */
196
349
  static resourceConfig() {
350
+ const attributes = this.sharedResourceStaticValue("attributes")
351
+ const abilities = this.sharedResourceStaticValue("abilities")
352
+ const attachments = this.sharedResourceStaticValue("attachments")
353
+ const commands = this.sharedResourceStaticValue("commands")
354
+ const builtInCollectionCommands = this.sharedResourceStaticValue("builtInCollectionCommands")
355
+ const builtInMemberCommands = this.sharedResourceStaticValue("builtInMemberCommands")
356
+ const collectionCommands = this.sharedResourceStaticValue("collectionCommands")
357
+ const memberCommands = this.sharedResourceStaticValue("memberCommands")
358
+ const modelName = this.sharedResourceStaticValue("modelName")
359
+ const primaryKey = this.sharedResourceStaticValue("primaryKey")
360
+ const relationships = this.sharedResourceStaticValue("relationships")
361
+ const server = this.sharedResourceStaticValue("server")
197
362
  /** @type {import("../configuration-types.js").FrontendModelResourceConfiguration} */
198
363
  const config = {
199
- attributes: this.attributes || []
364
+ attributes: /** @type {Record<string, ?> | string[]} */ (attributes || [])
200
365
  }
201
366
 
202
- if (this.abilities) config.abilities = this.abilities
203
- if (this.attachments) config.attachments = this.attachments
204
- if (this.builtInCollectionCommands) config.builtInCollectionCommands = this.builtInCollectionCommands
205
- if (this.builtInMemberCommands) config.builtInMemberCommands = this.builtInMemberCommands
206
- if (this.collectionCommands) config.collectionCommands = this.collectionCommands
207
- if (this.memberCommands) config.memberCommands = this.memberCommands
208
- if (this.relationships) config.relationships = this.relationships
367
+ if (abilities) config.abilities = /** @type {string[]} */ (abilities)
368
+ if (attachments) config.attachments = /** @type {Record<string, ?>} */ (attachments)
369
+ if (commands) config.commands = /** @type {string[]} */ (commands)
370
+ if (builtInCollectionCommands) config.builtInCollectionCommands = /** @type {string[]} */ (builtInCollectionCommands)
371
+ if (builtInMemberCommands) config.builtInMemberCommands = /** @type {string[]} */ (builtInMemberCommands)
372
+ if (collectionCommands) config.collectionCommands = /** @type {string[]} */ (collectionCommands)
373
+ if (memberCommands) config.memberCommands = /** @type {string[]} */ (memberCommands)
374
+ if (modelName) config.modelName = /** @type {string} */ (modelName)
375
+ if (primaryKey) config.primaryKey = /** @type {string} */ (primaryKey)
376
+ if (relationships) config.relationships = /** @type {string[]} */ (relationships)
377
+ if (server) config.server = /** @type {import("../configuration-types.js").FrontendModelResourceServerConfiguration} */ (server)
209
378
 
210
379
  return config
211
380
  }
@@ -242,6 +411,14 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
242
411
  return /** @type {TModelClass} */ (this.modelClassValue)
243
412
  }
244
413
 
414
+ /**
415
+ * Runs required model class for authorization helpers.
416
+ * @returns {TModelClass} - Backing model class.
417
+ */
418
+ requiredModelClass() {
419
+ return this.modelClass()
420
+ }
421
+
245
422
  /**
246
423
  * Runs model name.
247
424
  * @returns {string} - Model name.
@@ -307,9 +484,11 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
307
484
  * @returns {Array<string | Record<string, ?>>} - Permit spec.
308
485
  */
309
486
  permittedParams(arg) {
310
- void arg
487
+ return this.sharedResourceMethodOr("permittedParams", [arg], () => {
488
+ void arg
311
489
 
312
- return []
490
+ return []
491
+ })
313
492
  }
314
493
 
315
494
  /**
@@ -319,9 +498,11 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
319
498
  * @returns {FrontendModelResourceAttributePayload | Promise<FrontendModelResourceAttributePayload>} - Normalized attributes.
320
499
  */
321
500
  normalizeCreateAttributes(attributes, options) {
322
- void options
501
+ return this.sharedResourceMethodOr("normalizeCreateAttributes", [attributes, options], () => {
502
+ void options
323
503
 
324
- return attributes
504
+ return attributes
505
+ })
325
506
  }
326
507
 
327
508
  /**
@@ -332,10 +513,12 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
332
513
  * @returns {FrontendModelResourceAttributePayload | Promise<FrontendModelResourceAttributePayload>} - Normalized attributes.
333
514
  */
334
515
  normalizeUpdateAttributes(model, attributes, options) {
335
- void model
336
- void options
516
+ return this.sharedResourceMethodOr("normalizeUpdateAttributes", [model, attributes, options], () => {
517
+ void model
518
+ void options
337
519
 
338
- return attributes
520
+ return attributes
521
+ })
339
522
  }
340
523
 
341
524
  /**
@@ -346,9 +529,11 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
346
529
  * @returns {void | Promise<void>} - Resolves when the hook finishes.
347
530
  */
348
531
  beforeCreate(model, attributes, options) {
349
- void model
350
- void attributes
351
- void options
532
+ return this.sharedResourceMethodOr("beforeCreate", [model, attributes, options], () => {
533
+ void model
534
+ void attributes
535
+ void options
536
+ })
352
537
  }
353
538
 
354
539
  /**
@@ -359,9 +544,11 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
359
544
  * @returns {void | Promise<void>} - Resolves when the hook finishes.
360
545
  */
361
546
  afterCreate(model, attributes, options) {
362
- void model
363
- void attributes
364
- void options
547
+ return this.sharedResourceMethodOr("afterCreate", [model, attributes, options], () => {
548
+ void model
549
+ void attributes
550
+ void options
551
+ })
365
552
  }
366
553
 
367
554
  /**
@@ -372,9 +559,11 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
372
559
  * @returns {void | Promise<void>} - Resolves when the hook finishes.
373
560
  */
374
561
  beforeUpdate(model, attributes, options) {
375
- void model
376
- void attributes
377
- void options
562
+ return this.sharedResourceMethodOr("beforeUpdate", [model, attributes, options], () => {
563
+ void model
564
+ void attributes
565
+ void options
566
+ })
378
567
  }
379
568
 
380
569
  /**
@@ -385,9 +574,11 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
385
574
  * @returns {void | Promise<void>} - Resolves when the hook finishes.
386
575
  */
387
576
  afterUpdate(model, attributes, options) {
388
- void model
389
- void attributes
390
- void options
577
+ return this.sharedResourceMethodOr("afterUpdate", [model, attributes, options], () => {
578
+ void model
579
+ void attributes
580
+ void options
581
+ })
391
582
  }
392
583
 
393
584
  /**
@@ -396,7 +587,9 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
396
587
  * @returns {void | Promise<void>} - Resolves when the hook finishes.
397
588
  */
398
589
  beforeDestroy(model) {
399
- void model
590
+ return this.sharedResourceMethodOr("beforeDestroy", [model], () => {
591
+ void model
592
+ })
400
593
  }
401
594
 
402
595
  /**
@@ -405,7 +598,9 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
405
598
  * @returns {void | Promise<void>} - Resolves when the hook finishes.
406
599
  */
407
600
  afterDestroy(model) {
408
- void model
601
+ return this.sharedResourceMethodOr("afterDestroy", [model], () => {
602
+ void model
603
+ })
409
604
  }
410
605
 
411
606
  /**
@@ -418,10 +613,12 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
418
613
  * @returns {Promise<Result>} - Callback result.
419
614
  */
420
615
  async runMutationTransaction({action, model, callback}) {
421
- void action
422
- void model
616
+ return await this.sharedResourceMethodOr("runMutationTransaction", [{action, model, callback}], async () => {
617
+ void action
618
+ void model
423
619
 
424
- return await callback()
620
+ return await callback()
621
+ })
425
622
  }
426
623
 
427
624
  /**
@@ -517,9 +714,11 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
517
714
  * @returns {boolean | void | Promise<boolean | void>} - Continue processing unless false.
518
715
  */
519
716
  beforeAction(action) {
520
- void action
717
+ return this.sharedResourceMethodOr("beforeAction", [action], () => {
718
+ void action
521
719
 
522
- // No-op by default.
720
+ // No-op by default.
721
+ })
523
722
  }
524
723
 
525
724
  /**
@@ -663,14 +862,14 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
663
862
  /** @type {Record<string, ?>} */
664
863
  const directAttributes = {}
665
864
  const ResourceClass = /** @type {typeof FrontendModelBaseResource} */ (this.constructor)
666
- const translatedSet = new Set(ResourceClass.translatedAttributes || [])
865
+ const translatedSet = new Set(ResourceClass.translatedAttributesConfig() || [])
667
866
 
668
867
  for (const [name, value] of Object.entries(attributes)) {
669
868
  const resourceSetterName = `set${inflection.camelize(name)}Attribute`
670
- const resourceSetter = virtualSetterLookup(this)[resourceSetterName]
869
+ const resourceSetter = this.resourceMethod(resourceSetterName)
671
870
 
672
- if (typeof resourceSetter === "function") {
673
- await resourceSetter.call(this, model, value)
871
+ if (resourceSetter) {
872
+ await resourceSetter.method.call(resourceSetter.resource, model, value)
674
873
  } else if (translatedSet.has(name)) {
675
874
  await this._setTranslatedAttributeOnModel(model, name, value)
676
875
  } else {
@@ -1527,12 +1726,21 @@ function parsePermittedParams(permitSpec) {
1527
1726
  }
1528
1727
 
1529
1728
  /**
1530
- * Narrows a resource to its dynamic virtual setter lookup surface.
1531
- * @param {FrontendModelBaseResource} resource - Resource instance.
1532
- * @returns {Record<string, FrontendModelResourceVirtualSetter>} Dynamic virtual setter lookup.
1729
+ * Locates which prototype owns a method implementation.
1730
+ * @param {object} instance - Instance receiving the method.
1731
+ * @param {string} methodName - Method name.
1732
+ * @returns {object | null} - Prototype that owns the method.
1533
1733
  */
1534
- function virtualSetterLookup(resource) {
1535
- return /** @type {Record<string, FrontendModelResourceVirtualSetter>} */ (/** @type {unknown} */ (resource))
1734
+ function prototypeOwnerForMethod(instance, methodName) {
1735
+ let prototype = Object.getPrototypeOf(instance)
1736
+
1737
+ while (prototype) {
1738
+ if (Object.prototype.hasOwnProperty.call(prototype, methodName)) return prototype
1739
+
1740
+ prototype = Object.getPrototypeOf(prototype)
1741
+ }
1742
+
1743
+ return null
1536
1744
  }
1537
1745
 
1538
1746
  /**
@@ -1567,7 +1775,7 @@ function filterWritableFrontendModelAttributes(
1567
1775
  if (resource) {
1568
1776
  const ResourceClass = /** @type {typeof FrontendModelBaseResource} */ (resource.constructor)
1569
1777
 
1570
- translatedAttributes = ResourceClass.translatedAttributes || []
1778
+ translatedAttributes = ResourceClass.translatedAttributesConfig() || []
1571
1779
  }
1572
1780
 
1573
1781
  const translatedSet = new Set(translatedAttributes)
@@ -1582,11 +1790,11 @@ function filterWritableFrontendModelAttributes(
1582
1790
  const requestedSetterName = `set${inflection.camelize(resolvedAttributeName)}`
1583
1791
  const setterName = receiverClass.findMemberNameInsensitive(receiver, requestedSetterName) || requestedSetterName
1584
1792
  const resourceSetterName = `set${inflection.camelize(attributeName)}Attribute`
1585
- const resourceSetter = resource ? virtualSetterLookup(resource)[resourceSetterName] : undefined
1793
+ const resourceSetter = resource?.resourceMethod(resourceSetterName)
1586
1794
 
1587
1795
  if (setterName in receiver) {
1588
1796
  writableAttributes[attributeName] = value
1589
- } else if (typeof resourceSetter === "function") {
1797
+ } else if (resourceSetter) {
1590
1798
  writableAttributes[attributeName] = value
1591
1799
  } else if (translatedSet.has(attributeName)) {
1592
1800
  writableAttributes[attributeName] = value