velocious 1.0.472 → 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 (42) 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 +21 -17
  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/frontend-models/websocket-channel.js +24 -20
  9. package/build/routes/resolver.js +7 -5
  10. package/build/src/authorization/base-resource.d.ts +41 -0
  11. package/build/src/authorization/base-resource.d.ts.map +1 -1
  12. package/build/src/authorization/base-resource.js +88 -1
  13. package/build/src/configuration-types.d.ts +15 -3
  14. package/build/src/configuration-types.d.ts.map +1 -1
  15. package/build/src/configuration-types.js +4 -3
  16. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +2 -2
  17. package/build/src/frontend-model-controller.d.ts.map +1 -1
  18. package/build/src/frontend-model-controller.js +22 -18
  19. package/build/src/frontend-model-resource/base-resource.d.ts +67 -0
  20. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  21. package/build/src/frontend-model-resource/base-resource.js +242 -54
  22. package/build/src/frontend-model-resource/velocious-attachment-resource.d.ts +0 -2
  23. package/build/src/frontend-model-resource/velocious-attachment-resource.d.ts.map +1 -1
  24. package/build/src/frontend-model-resource/velocious-attachment-resource.js +1 -3
  25. package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
  26. package/build/src/frontend-models/resource-definition.js +83 -25
  27. package/build/src/frontend-models/websocket-channel.d.ts +6 -0
  28. package/build/src/frontend-models/websocket-channel.d.ts.map +1 -1
  29. package/build/src/frontend-models/websocket-channel.js +23 -19
  30. package/build/src/routes/resolver.d.ts.map +1 -1
  31. package/build/src/routes/resolver.js +8 -6
  32. package/package.json +1 -1
  33. package/scripts/tensorbuzz-retry +21 -0
  34. package/src/authorization/base-resource.js +99 -0
  35. package/src/configuration-types.js +3 -2
  36. package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +1 -1
  37. package/src/frontend-model-controller.js +21 -17
  38. package/src/frontend-model-resource/base-resource.js +254 -46
  39. package/src/frontend-model-resource/velocious-attachment-resource.js +0 -3
  40. package/src/frontend-models/resource-definition.js +96 -21
  41. package/src/frontend-models/websocket-channel.js +24 -20
  42. package/src/routes/resolver.js +7 -5
@@ -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
@@ -26,9 +26,6 @@ export default class VelociousAttachmentResource extends FrontendModelBaseResour
26
26
  updatedAt: {type: "datetime"}
27
27
  }
28
28
 
29
- /** @type {string[]} */
30
- static abilities = ["read"]
31
-
32
29
  /** @type {string[]} */
33
30
  static builtInCollectionCommands = ["index"]
34
31
 
@@ -5,6 +5,25 @@ import FrontendModelBaseResource from "../frontend-model-resource/base-resource.
5
5
  import restArgsError from "../utils/rest-args-error.js"
6
6
  import {validateFrontendModelResourceCommandName} from "./resource-config-validation.js"
7
7
 
8
+ const BASE_FRONTEND_MODEL_ABILITY_ACTIONS = ["create", "destroy", "read", "update"]
9
+ const RESOURCE_STATIC_CONFIG_KEYS = new Set([
10
+ "abilities",
11
+ "attachments",
12
+ "attributes",
13
+ "builtInCollectionCommands",
14
+ "builtInMemberCommands",
15
+ "collectionCommands",
16
+ "commands",
17
+ "memberCommands",
18
+ "modelName",
19
+ "ModelClass",
20
+ "primaryKey",
21
+ "relationships",
22
+ "server",
23
+ "SharedResource",
24
+ "translatedAttributes"
25
+ ])
26
+
8
27
  /**
9
28
  * Runs the frontendModelResourcesForBackendProject helper.
10
29
  * @param {import("../configuration-types.js").BackendProjectConfiguration} backendProject - Backend project config.
@@ -50,9 +69,74 @@ export function frontendModelResourceClassFromDefinition(resourceDefinition) {
50
69
  export function frontendModelResourceConfigurationFromDefinition(resourceDefinition) {
51
70
  if (!frontendModelResourceDefinitionIsClass(resourceDefinition)) return null
52
71
 
72
+ assertResourceConfigIsFrameworkDefined(resourceDefinition)
73
+
53
74
  return normalizeFrontendModelResourceConfiguration(resourceDefinition.resourceConfig())
54
75
  }
55
76
 
77
+ /**
78
+ * Ensures resources use declarative static config properties instead of overriding resourceConfig().
79
+ * @param {import("../configuration-types.js").FrontendModelResourceClassType} ResourceClass - Resource class.
80
+ * @param {Set<import("../configuration-types.js").FrontendModelResourceClassType>} [visited] - Already inspected shared resources.
81
+ * @returns {void}
82
+ */
83
+ function assertResourceConfigIsFrameworkDefined(ResourceClass, visited = new Set()) {
84
+ if (visited.has(ResourceClass)) return
85
+
86
+ visited.add(ResourceClass)
87
+ assertKnownResourceStaticConfigProperties(ResourceClass)
88
+
89
+ const owner = staticMethodOwnerFor(ResourceClass, "resourceConfig")
90
+
91
+ if (owner && owner !== FrontendModelBaseResource) {
92
+ throw new Error(`${ResourceClass.name} overrides static resourceConfig(), which is not supported. Use static resource properties instead.`)
93
+ }
94
+
95
+ const SharedResource = ResourceClass.sharedResourceClass()
96
+
97
+ if (SharedResource) assertResourceConfigIsFrameworkDefined(SharedResource, visited)
98
+ }
99
+
100
+ /**
101
+ * Ensures declarative static resource config does not silently ignore typos or removed keys.
102
+ * @param {import("../configuration-types.js").FrontendModelResourceClassType} ResourceClass - Resource class.
103
+ * @returns {void}
104
+ */
105
+ function assertKnownResourceStaticConfigProperties(ResourceClass) {
106
+ let currentClass = ResourceClass
107
+
108
+ while (currentClass && currentClass !== FrontendModelBaseResource && currentClass !== Function.prototype) {
109
+ /** @type {Record<string, ?>} */
110
+ const unknownStaticConfig = {}
111
+
112
+ for (const key of Object.keys(currentClass)) {
113
+ if (!RESOURCE_STATIC_CONFIG_KEYS.has(key)) unknownStaticConfig[key] = /** @type {Record<string, ?>} */ (/** @type {unknown} */ (currentClass))[key]
114
+ }
115
+
116
+ restArgsError(unknownStaticConfig)
117
+
118
+ currentClass = Object.getPrototypeOf(currentClass)
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Locates which constructor owns a static method implementation.
124
+ * @param {import("../configuration-types.js").FrontendModelResourceClassType} ResourceClass - Resource class.
125
+ * @param {string} methodName - Method name.
126
+ * @returns {import("../configuration-types.js").FrontendModelResourceClassType | typeof FrontendModelBaseResource | null} - Class that owns the static method.
127
+ */
128
+ function staticMethodOwnerFor(ResourceClass, methodName) {
129
+ let currentClass = ResourceClass
130
+
131
+ while (currentClass && currentClass !== Function.prototype) {
132
+ if (Object.prototype.hasOwnProperty.call(currentClass, methodName)) return currentClass
133
+
134
+ currentClass = Object.getPrototypeOf(currentClass)
135
+ }
136
+
137
+ return null
138
+ }
139
+
56
140
  /**
57
141
  * Runs normalize frontend model resource configuration.
58
142
  * @param {import("../configuration-types.js").FrontendModelResourceConfiguration} resourceConfiguration - Raw resource configuration.
@@ -102,36 +186,27 @@ function normalizeFrontendModelResourceConfiguration(resourceConfiguration) {
102
186
  * @returns {Record<string, string>} - Normalized abilities config.
103
187
  */
104
188
  function normalizeFrontendModelResourceAbilities(abilities) {
105
- if (abilities === undefined) {
106
- return defaultCrudAbilities()
107
- }
189
+ const normalized = defaultCrudAbilities()
190
+
191
+ if (abilities === undefined) return normalized
108
192
 
109
193
  if (!Array.isArray(abilities)) {
110
194
  throw new Error("Resource abilities must be an array of action names. Object form is no longer supported.")
111
195
  }
112
196
 
113
- if (abilities.includes("manage")) {
114
- return {
115
- create: "manage",
116
- destroy: "manage",
117
- find: "manage",
118
- index: "manage",
119
- update: "manage"
120
- }
197
+ const duplicatedBaseAbilities = abilities.filter((ability) => BASE_FRONTEND_MODEL_ABILITY_ACTIONS.includes(ability))
198
+
199
+ if (duplicatedBaseAbilities.length > 0) {
200
+ throw new Error(`Resource abilities must not include base actions: ${duplicatedBaseAbilities.join(", ")}`)
121
201
  }
122
202
 
123
- /**
124
- * Normalized.
125
- * @type {Record<string, string>} */
126
- const normalized = {}
203
+ for (const ability of abilities) {
204
+ if (typeof ability !== "string" || ability.length < 1) {
205
+ throw new Error("Resource abilities entries must be non-empty strings.")
206
+ }
127
207
 
128
- if (abilities.includes("create")) normalized.create = "create"
129
- if (abilities.includes("destroy")) normalized.destroy = "destroy"
130
- if (abilities.includes("read")) {
131
- normalized.find = "read"
132
- normalized.index = "read"
208
+ normalized[ability] = ability
133
209
  }
134
- if (abilities.includes("update")) normalized.update = "update"
135
210
 
136
211
  return normalized
137
212
  }
@@ -106,15 +106,6 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
106
106
  * @returns {Promise<void>} Resolves after delivery.
107
107
  */
108
108
  async deliverBroadcast(body, meta) {
109
- const configuration = this.session.configuration
110
-
111
- if (configuration) {
112
- await configuration.ensureConnections({name: "Frontend model websocket broadcast"}, async () => {
113
- await this._deliverBroadcast(body, meta)
114
- })
115
- return
116
- }
117
-
118
109
  await this._deliverBroadcast(body, meta)
119
110
  }
120
111
 
@@ -367,6 +358,29 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
367
358
  return controller
368
359
  }
369
360
 
361
+ /**
362
+ * Resolves tenant for event.
363
+ * @param {string | number} id - Event record id.
364
+ * @returns {Promise<?>} - Resolved tenant.
365
+ */
366
+ async _resolveEventTenant(id) {
367
+ const configuration = this.session.configuration
368
+
369
+ return await configuration.ensureConnections({name: "Frontend model websocket event tenant resolution"}, async () => {
370
+ // Mirror the subscribe-time tenant resolution (`WebsocketSession._resolveTenant`):
371
+ // pass `subscription: {channel, params}` so resolvers that derive scope from the
372
+ // subscription behave the same for broadcasts as they did at `channel-subscribe`.
373
+ // The synthetic request forwards the subscriber's params (e.g. authenticationToken),
374
+ // matching this channel's ability resolution above.
375
+ return await configuration.resolveTenant({
376
+ params: {...this.params, id, model: this._modelName()},
377
+ request: /** @type {import("../http-server/client/request.js").default} */ (this._syntheticRequest()),
378
+ response: new Response({configuration}),
379
+ subscription: {channel: FRONTEND_MODELS_CHANNEL_NAME, params: this.params}
380
+ })
381
+ })
382
+ }
383
+
370
384
  /**
371
385
  * Resolves the subscriber's tenant for the broadcast record and runs `callback` inside that tenant
372
386
  * context. Broadcast delivery runs in whatever ambient tenant context the publisher left behind. For
@@ -388,17 +402,7 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
388
402
  return await callback()
389
403
  }
390
404
 
391
- // Mirror the subscribe-time tenant resolution (`WebsocketSession._resolveTenant`):
392
- // pass `subscription: {channel, params}` so resolvers that derive scope from the
393
- // subscription behave the same for broadcasts as they did at `channel-subscribe`.
394
- // The synthetic request forwards the subscriber's params (e.g. authenticationToken),
395
- // matching this channel's ability resolution above.
396
- const tenant = await configuration.resolveTenant({
397
- params: {...this.params, id, model: this._modelName()},
398
- request: /** @type {import("../http-server/client/request.js").default} */ (this._syntheticRequest()),
399
- response: new Response({configuration}),
400
- subscription: {channel: FRONTEND_MODELS_CHANNEL_NAME, params: this.params}
401
- })
405
+ const tenant = await this._resolveEventTenant(id)
402
406
 
403
407
  // Always enter `runWithTenant`, even when no tenant resolved. Broadcast fan-out
404
408
  // runs in the publisher's ambient tenant context; falling back to `callback()`