velocious 1.0.473 → 1.0.475

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 (96) hide show
  1. package/README.md +7 -2
  2. package/build/authorization/base-resource.js +99 -0
  3. package/build/configuration-types.js +41 -3
  4. package/build/configuration.js +41 -1
  5. package/build/database/drivers/base.js +51 -3
  6. package/build/database/drivers/mysql/index.js +74 -7
  7. package/build/database/drivers/sqlite/connection-sql-js.js +16 -5
  8. package/build/database/drivers/sqlite/index.web.js +29 -15
  9. package/build/database/drivers/sqlite/web-persistence.js +521 -0
  10. package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +64 -1
  11. package/build/frontend-model-controller.js +139 -16
  12. package/build/frontend-model-resource/base-resource.js +258 -46
  13. package/build/frontend-model-resource/velocious-attachment-resource.js +0 -3
  14. package/build/frontend-models/base.js +146 -2
  15. package/build/frontend-models/query.js +3 -3
  16. package/build/frontend-models/resource-definition.js +400 -23
  17. package/build/routes/hooks/frontend-model-command-route-hook.js +8 -0
  18. package/build/src/authorization/base-resource.d.ts +41 -0
  19. package/build/src/authorization/base-resource.d.ts.map +1 -1
  20. package/build/src/authorization/base-resource.js +88 -1
  21. package/build/src/configuration-types.d.ts +128 -5
  22. package/build/src/configuration-types.d.ts.map +1 -1
  23. package/build/src/configuration-types.js +38 -4
  24. package/build/src/configuration.d.ts +18 -1
  25. package/build/src/configuration.d.ts.map +1 -1
  26. package/build/src/configuration.js +37 -2
  27. package/build/src/database/drivers/base.d.ts +30 -0
  28. package/build/src/database/drivers/base.d.ts.map +1 -1
  29. package/build/src/database/drivers/base.js +47 -4
  30. package/build/src/database/drivers/mysql/index.d.ts +32 -3
  31. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  32. package/build/src/database/drivers/mysql/index.js +65 -7
  33. package/build/src/database/drivers/sqlite/connection-sql-js.d.ts +8 -1
  34. package/build/src/database/drivers/sqlite/connection-sql-js.d.ts.map +1 -1
  35. package/build/src/database/drivers/sqlite/connection-sql-js.js +15 -6
  36. package/build/src/database/drivers/sqlite/index.web.d.ts +5 -5
  37. package/build/src/database/drivers/sqlite/index.web.d.ts.map +1 -1
  38. package/build/src/database/drivers/sqlite/index.web.js +27 -13
  39. package/build/src/database/drivers/sqlite/web-persistence.d.ts +77 -0
  40. package/build/src/database/drivers/sqlite/web-persistence.d.ts.map +1 -0
  41. package/build/src/database/drivers/sqlite/web-persistence.js +464 -0
  42. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts +30 -0
  43. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
  44. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +54 -2
  45. package/build/src/frontend-model-controller.d.ts +34 -0
  46. package/build/src/frontend-model-controller.d.ts.map +1 -1
  47. package/build/src/frontend-model-controller.js +125 -17
  48. package/build/src/frontend-model-resource/base-resource.d.ts +69 -0
  49. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  50. package/build/src/frontend-model-resource/base-resource.js +247 -54
  51. package/build/src/frontend-model-resource/velocious-attachment-resource.d.ts +0 -2
  52. package/build/src/frontend-model-resource/velocious-attachment-resource.d.ts.map +1 -1
  53. package/build/src/frontend-model-resource/velocious-attachment-resource.js +1 -3
  54. package/build/src/frontend-models/base.d.ts +29 -0
  55. package/build/src/frontend-models/base.d.ts.map +1 -1
  56. package/build/src/frontend-models/base.js +131 -3
  57. package/build/src/frontend-models/query.js +4 -4
  58. package/build/src/frontend-models/resource-definition.d.ts +6 -0
  59. package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
  60. package/build/src/frontend-models/resource-definition.js +355 -28
  61. package/build/src/routes/hooks/frontend-model-command-route-hook.d.ts.map +1 -1
  62. package/build/src/routes/hooks/frontend-model-command-route-hook.js +8 -1
  63. package/build/src/sync/device-identity.d.ts +240 -0
  64. package/build/src/sync/device-identity.d.ts.map +1 -0
  65. package/build/src/sync/device-identity.js +454 -0
  66. package/build/src/sync/local-mutation-log.d.ts +212 -0
  67. package/build/src/sync/local-mutation-log.d.ts.map +1 -0
  68. package/build/src/sync/local-mutation-log.js +404 -0
  69. package/build/src/sync/offline-grant.d.ts +157 -0
  70. package/build/src/sync/offline-grant.d.ts.map +1 -0
  71. package/build/src/sync/offline-grant.js +292 -0
  72. package/build/sync/device-identity.js +503 -0
  73. package/build/sync/local-mutation-log.js +448 -0
  74. package/build/sync/offline-grant.js +327 -0
  75. package/build/tsconfig.tsbuildinfo +1 -1
  76. package/package.json +3 -3
  77. package/scripts/tensorbuzz-retry +21 -0
  78. package/src/authorization/base-resource.js +99 -0
  79. package/src/configuration-types.js +41 -3
  80. package/src/configuration.js +41 -1
  81. package/src/database/drivers/base.js +51 -3
  82. package/src/database/drivers/mysql/index.js +74 -7
  83. package/src/database/drivers/sqlite/connection-sql-js.js +16 -5
  84. package/src/database/drivers/sqlite/index.web.js +29 -15
  85. package/src/database/drivers/sqlite/web-persistence.js +521 -0
  86. package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +64 -1
  87. package/src/frontend-model-controller.js +139 -16
  88. package/src/frontend-model-resource/base-resource.js +258 -46
  89. package/src/frontend-model-resource/velocious-attachment-resource.js +0 -3
  90. package/src/frontend-models/base.js +146 -2
  91. package/src/frontend-models/query.js +3 -3
  92. package/src/frontend-models/resource-definition.js +400 -23
  93. package/src/routes/hooks/frontend-model-command-route-hook.js +8 -0
  94. package/src/sync/device-identity.js +503 -0
  95. package/src/sync/local-mutation-log.js +448 -0
  96. package/src/sync/offline-grant.js +327 -0
@@ -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,18 @@ 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
168
+ /** @type {import("../configuration-types.js").FrontendModelResourceSyncConfiguration | boolean | undefined} */
169
+ static sync = undefined
160
170
  /** @type {string[] | undefined} */
161
171
  static translatedAttributes = undefined
172
+ /** @type {?} */
173
+ static SharedResource = undefined
162
174
 
163
175
  /**
164
176
  * Runs constructor.
@@ -179,6 +191,149 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
179
191
  this.modelNameValue = "modelName" in args ? args.modelName : this.modelClass().getModelName()
180
192
  this.paramsValue = "params" in args ? args.params : undefined
181
193
  this.resourceConfigurationValue = "resourceConfiguration" in args ? args.resourceConfiguration : defaultResourceConfiguration
194
+ /** @type {FrontendModelBaseResource | null | undefined} */
195
+ this.sharedResourceInstanceValue = undefined
196
+ }
197
+
198
+ /**
199
+ * Returns the configured shared resource class.
200
+ * @returns {?} - Shared resource class.
201
+ */
202
+ static sharedResourceClass() {
203
+ return this.SharedResource
204
+ }
205
+
206
+ /**
207
+ * Reads a static resource config value from the environment resource first,
208
+ * then from the shared resource.
209
+ * @param {"abilities" | "attachments" | "attributes" | "builtInCollectionCommands" | "builtInMemberCommands" | "collectionCommands" | "commands" | "memberCommands" | "modelName" | "primaryKey" | "relationships" | "server" | "sync" | "translatedAttributes"} name - Static config property name.
210
+ * @returns {?} - Resolved config value.
211
+ */
212
+ static sharedResourceStaticValue(name) {
213
+ if (this[name] !== undefined) return this[name]
214
+
215
+ const SharedResource = /** @type {typeof FrontendModelBaseResource | undefined} */ (this.sharedResourceClass())
216
+
217
+ if (!SharedResource) return undefined
218
+ if (SharedResource[name] !== undefined) return SharedResource[name]
219
+
220
+ return undefined
221
+ }
222
+
223
+ /**
224
+ * Resolves translated attributes from environment and shared resources.
225
+ * @returns {string[] | undefined} - Translated attribute names.
226
+ */
227
+ static translatedAttributesConfig() {
228
+ return /** @type {string[] | undefined} */ (this.sharedResourceStaticValue("translatedAttributes"))
229
+ }
230
+
231
+ /**
232
+ * Builds a resource instance for shared-resource fallback calls.
233
+ * @returns {FrontendModelBaseResource | null} - Shared resource instance when configured.
234
+ */
235
+ sharedResourceInstance() {
236
+ if (this.sharedResourceInstanceValue !== undefined) return this.sharedResourceInstanceValue
237
+
238
+ const ResourceClass = /** @type {typeof FrontendModelBaseResource} */ (this.constructor)
239
+ const SharedResource = /** @type {typeof FrontendModelBaseResource | undefined} */ (ResourceClass.sharedResourceClass())
240
+
241
+ if (!SharedResource) {
242
+ this.sharedResourceInstanceValue = null
243
+ return this.sharedResourceInstanceValue
244
+ }
245
+
246
+ if (SharedResource === ResourceClass) {
247
+ throw new Error(`${ResourceClass.name}.SharedResource cannot point to itself.`)
248
+ }
249
+
250
+ this.sharedResourceInstanceValue = new SharedResource({
251
+ ability: this.ability,
252
+ controller: this.controller,
253
+ context: this.context,
254
+ locals: this.locals,
255
+ modelClass: this.modelClass(),
256
+ modelName: this.modelName(),
257
+ params: this.params(),
258
+ resourceConfiguration: this.resourceConfiguration()
259
+ })
260
+
261
+ return this.sharedResourceInstanceValue
262
+ }
263
+
264
+ /**
265
+ * Calls a shared-resource method only when the shared resource overrides the framework default.
266
+ * @param {string} methodName - Method name to resolve.
267
+ * @param {unknown[]} args - Method args.
268
+ * @returns {{called: boolean, result: unknown}} - Shared method call result.
269
+ */
270
+ callSharedResourceMethod(methodName, args) {
271
+ const sharedResource = this.sharedResourceInstance()
272
+
273
+ if (!sharedResource) return {called: false, result: undefined}
274
+
275
+ const methodOwner = prototypeOwnerForMethod(sharedResource, methodName)
276
+
277
+ if (!methodOwner || methodOwner === FrontendModelBaseResource.prototype || methodOwner === AuthorizationBaseResource.prototype) {
278
+ return {called: false, result: undefined}
279
+ }
280
+
281
+ const method = /** @type {Record<string, (...methodArgs: unknown[]) => unknown>} */ (/** @type {unknown} */ (sharedResource))[methodName]
282
+
283
+ return {called: true, result: method.apply(sharedResource, args)}
284
+ }
285
+
286
+ /**
287
+ * Runs shared method result or a fallback callback.
288
+ * @template Result
289
+ * @param {string} methodName - Shared method name.
290
+ * @param {unknown[]} args - Shared method args.
291
+ * @param {() => Result} fallback - Fallback callback.
292
+ * @returns {Result} - Shared or fallback result.
293
+ */
294
+ sharedResourceMethodOr(methodName, args, fallback) {
295
+ const sharedResult = this.callSharedResourceMethod(methodName, args)
296
+
297
+ if (sharedResult.called) return /** @type {Result} */ (sharedResult.result)
298
+
299
+ return fallback()
300
+ }
301
+
302
+ /**
303
+ * Resolves a method on this resource or its shared fallback.
304
+ * @param {string} methodName - Method name.
305
+ * @returns {{method: (...methodArgs: unknown[]) => unknown, resource: FrontendModelBaseResource} | null} - Resolved method and receiver.
306
+ */
307
+ resourceMethod(methodName) {
308
+ const ownMethod = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (this))[methodName]
309
+
310
+ if (typeof ownMethod === "function") {
311
+ return {
312
+ method: /** @type {(...methodArgs: unknown[]) => unknown} */ (ownMethod),
313
+ resource: this
314
+ }
315
+ }
316
+
317
+ const sharedResource = this.sharedResourceInstance()
318
+
319
+ if (!sharedResource) return null
320
+
321
+ const sharedMethod = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (sharedResource))[methodName]
322
+
323
+ if (typeof sharedMethod !== "function") return null
324
+
325
+ return {
326
+ method: /** @type {(...methodArgs: unknown[]) => unknown} */ (sharedMethod),
327
+ resource: sharedResource
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Runs abilities.
333
+ * @returns {void} - No return value.
334
+ */
335
+ abilities() {
336
+ this.sharedResourceMethodOr("abilities", [], () => undefined)
182
337
  }
183
338
 
184
339
  /**
@@ -194,18 +349,36 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
194
349
  * @returns {import("../configuration-types.js").FrontendModelResourceConfiguration} - Static resource config (raw user input shape; consumers normalize).
195
350
  */
196
351
  static resourceConfig() {
352
+ const attributes = this.sharedResourceStaticValue("attributes")
353
+ const abilities = this.sharedResourceStaticValue("abilities")
354
+ const attachments = this.sharedResourceStaticValue("attachments")
355
+ const commands = this.sharedResourceStaticValue("commands")
356
+ const builtInCollectionCommands = this.sharedResourceStaticValue("builtInCollectionCommands")
357
+ const builtInMemberCommands = this.sharedResourceStaticValue("builtInMemberCommands")
358
+ const collectionCommands = this.sharedResourceStaticValue("collectionCommands")
359
+ const memberCommands = this.sharedResourceStaticValue("memberCommands")
360
+ const modelName = this.sharedResourceStaticValue("modelName")
361
+ const primaryKey = this.sharedResourceStaticValue("primaryKey")
362
+ const relationships = this.sharedResourceStaticValue("relationships")
363
+ const server = this.sharedResourceStaticValue("server")
364
+ const sync = this.sharedResourceStaticValue("sync")
197
365
  /** @type {import("../configuration-types.js").FrontendModelResourceConfiguration} */
198
366
  const config = {
199
- attributes: this.attributes || []
367
+ attributes: /** @type {Record<string, ?> | string[]} */ (attributes || [])
200
368
  }
201
369
 
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
370
+ if (abilities) config.abilities = /** @type {string[]} */ (abilities)
371
+ if (attachments) config.attachments = /** @type {Record<string, ?>} */ (attachments)
372
+ if (commands) config.commands = /** @type {string[]} */ (commands)
373
+ if (builtInCollectionCommands) config.builtInCollectionCommands = /** @type {string[]} */ (builtInCollectionCommands)
374
+ if (builtInMemberCommands) config.builtInMemberCommands = /** @type {string[]} */ (builtInMemberCommands)
375
+ if (collectionCommands) config.collectionCommands = /** @type {string[]} */ (collectionCommands)
376
+ if (memberCommands) config.memberCommands = /** @type {string[]} */ (memberCommands)
377
+ if (modelName) config.modelName = /** @type {string} */ (modelName)
378
+ if (primaryKey) config.primaryKey = /** @type {string} */ (primaryKey)
379
+ if (relationships) config.relationships = /** @type {string[]} */ (relationships)
380
+ if (server) config.server = /** @type {import("../configuration-types.js").FrontendModelResourceServerConfiguration} */ (server)
381
+ if (sync !== undefined) config.sync = /** @type {import("../configuration-types.js").FrontendModelResourceSyncConfiguration | boolean} */ (sync)
209
382
 
210
383
  return config
211
384
  }
@@ -242,6 +415,14 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
242
415
  return /** @type {TModelClass} */ (this.modelClassValue)
243
416
  }
244
417
 
418
+ /**
419
+ * Runs required model class for authorization helpers.
420
+ * @returns {TModelClass} - Backing model class.
421
+ */
422
+ requiredModelClass() {
423
+ return this.modelClass()
424
+ }
425
+
245
426
  /**
246
427
  * Runs model name.
247
428
  * @returns {string} - Model name.
@@ -307,9 +488,11 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
307
488
  * @returns {Array<string | Record<string, ?>>} - Permit spec.
308
489
  */
309
490
  permittedParams(arg) {
310
- void arg
491
+ return this.sharedResourceMethodOr("permittedParams", [arg], () => {
492
+ void arg
311
493
 
312
- return []
494
+ return []
495
+ })
313
496
  }
314
497
 
315
498
  /**
@@ -319,9 +502,11 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
319
502
  * @returns {FrontendModelResourceAttributePayload | Promise<FrontendModelResourceAttributePayload>} - Normalized attributes.
320
503
  */
321
504
  normalizeCreateAttributes(attributes, options) {
322
- void options
505
+ return this.sharedResourceMethodOr("normalizeCreateAttributes", [attributes, options], () => {
506
+ void options
323
507
 
324
- return attributes
508
+ return attributes
509
+ })
325
510
  }
326
511
 
327
512
  /**
@@ -332,10 +517,12 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
332
517
  * @returns {FrontendModelResourceAttributePayload | Promise<FrontendModelResourceAttributePayload>} - Normalized attributes.
333
518
  */
334
519
  normalizeUpdateAttributes(model, attributes, options) {
335
- void model
336
- void options
520
+ return this.sharedResourceMethodOr("normalizeUpdateAttributes", [model, attributes, options], () => {
521
+ void model
522
+ void options
337
523
 
338
- return attributes
524
+ return attributes
525
+ })
339
526
  }
340
527
 
341
528
  /**
@@ -346,9 +533,11 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
346
533
  * @returns {void | Promise<void>} - Resolves when the hook finishes.
347
534
  */
348
535
  beforeCreate(model, attributes, options) {
349
- void model
350
- void attributes
351
- void options
536
+ return this.sharedResourceMethodOr("beforeCreate", [model, attributes, options], () => {
537
+ void model
538
+ void attributes
539
+ void options
540
+ })
352
541
  }
353
542
 
354
543
  /**
@@ -359,9 +548,11 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
359
548
  * @returns {void | Promise<void>} - Resolves when the hook finishes.
360
549
  */
361
550
  afterCreate(model, attributes, options) {
362
- void model
363
- void attributes
364
- void options
551
+ return this.sharedResourceMethodOr("afterCreate", [model, attributes, options], () => {
552
+ void model
553
+ void attributes
554
+ void options
555
+ })
365
556
  }
366
557
 
367
558
  /**
@@ -372,9 +563,11 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
372
563
  * @returns {void | Promise<void>} - Resolves when the hook finishes.
373
564
  */
374
565
  beforeUpdate(model, attributes, options) {
375
- void model
376
- void attributes
377
- void options
566
+ return this.sharedResourceMethodOr("beforeUpdate", [model, attributes, options], () => {
567
+ void model
568
+ void attributes
569
+ void options
570
+ })
378
571
  }
379
572
 
380
573
  /**
@@ -385,9 +578,11 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
385
578
  * @returns {void | Promise<void>} - Resolves when the hook finishes.
386
579
  */
387
580
  afterUpdate(model, attributes, options) {
388
- void model
389
- void attributes
390
- void options
581
+ return this.sharedResourceMethodOr("afterUpdate", [model, attributes, options], () => {
582
+ void model
583
+ void attributes
584
+ void options
585
+ })
391
586
  }
392
587
 
393
588
  /**
@@ -396,7 +591,9 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
396
591
  * @returns {void | Promise<void>} - Resolves when the hook finishes.
397
592
  */
398
593
  beforeDestroy(model) {
399
- void model
594
+ return this.sharedResourceMethodOr("beforeDestroy", [model], () => {
595
+ void model
596
+ })
400
597
  }
401
598
 
402
599
  /**
@@ -405,7 +602,9 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
405
602
  * @returns {void | Promise<void>} - Resolves when the hook finishes.
406
603
  */
407
604
  afterDestroy(model) {
408
- void model
605
+ return this.sharedResourceMethodOr("afterDestroy", [model], () => {
606
+ void model
607
+ })
409
608
  }
410
609
 
411
610
  /**
@@ -418,10 +617,12 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
418
617
  * @returns {Promise<Result>} - Callback result.
419
618
  */
420
619
  async runMutationTransaction({action, model, callback}) {
421
- void action
422
- void model
620
+ return await this.sharedResourceMethodOr("runMutationTransaction", [{action, model, callback}], async () => {
621
+ void action
622
+ void model
423
623
 
424
- return await callback()
624
+ return await callback()
625
+ })
425
626
  }
426
627
 
427
628
  /**
@@ -517,9 +718,11 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
517
718
  * @returns {boolean | void | Promise<boolean | void>} - Continue processing unless false.
518
719
  */
519
720
  beforeAction(action) {
520
- void action
721
+ return this.sharedResourceMethodOr("beforeAction", [action], () => {
722
+ void action
521
723
 
522
- // No-op by default.
724
+ // No-op by default.
725
+ })
523
726
  }
524
727
 
525
728
  /**
@@ -663,14 +866,14 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
663
866
  /** @type {Record<string, ?>} */
664
867
  const directAttributes = {}
665
868
  const ResourceClass = /** @type {typeof FrontendModelBaseResource} */ (this.constructor)
666
- const translatedSet = new Set(ResourceClass.translatedAttributes || [])
869
+ const translatedSet = new Set(ResourceClass.translatedAttributesConfig() || [])
667
870
 
668
871
  for (const [name, value] of Object.entries(attributes)) {
669
872
  const resourceSetterName = `set${inflection.camelize(name)}Attribute`
670
- const resourceSetter = virtualSetterLookup(this)[resourceSetterName]
873
+ const resourceSetter = this.resourceMethod(resourceSetterName)
671
874
 
672
- if (typeof resourceSetter === "function") {
673
- await resourceSetter.call(this, model, value)
875
+ if (resourceSetter) {
876
+ await resourceSetter.method.call(resourceSetter.resource, model, value)
674
877
  } else if (translatedSet.has(name)) {
675
878
  await this._setTranslatedAttributeOnModel(model, name, value)
676
879
  } else {
@@ -1527,12 +1730,21 @@ function parsePermittedParams(permitSpec) {
1527
1730
  }
1528
1731
 
1529
1732
  /**
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.
1733
+ * Locates which prototype owns a method implementation.
1734
+ * @param {object} instance - Instance receiving the method.
1735
+ * @param {string} methodName - Method name.
1736
+ * @returns {object | null} - Prototype that owns the method.
1533
1737
  */
1534
- function virtualSetterLookup(resource) {
1535
- return /** @type {Record<string, FrontendModelResourceVirtualSetter>} */ (/** @type {unknown} */ (resource))
1738
+ function prototypeOwnerForMethod(instance, methodName) {
1739
+ let prototype = Object.getPrototypeOf(instance)
1740
+
1741
+ while (prototype) {
1742
+ if (Object.prototype.hasOwnProperty.call(prototype, methodName)) return prototype
1743
+
1744
+ prototype = Object.getPrototypeOf(prototype)
1745
+ }
1746
+
1747
+ return null
1536
1748
  }
1537
1749
 
1538
1750
  /**
@@ -1567,7 +1779,7 @@ function filterWritableFrontendModelAttributes(
1567
1779
  if (resource) {
1568
1780
  const ResourceClass = /** @type {typeof FrontendModelBaseResource} */ (resource.constructor)
1569
1781
 
1570
- translatedAttributes = ResourceClass.translatedAttributes || []
1782
+ translatedAttributes = ResourceClass.translatedAttributesConfig() || []
1571
1783
  }
1572
1784
 
1573
1785
  const translatedSet = new Set(translatedAttributes)
@@ -1582,11 +1794,11 @@ function filterWritableFrontendModelAttributes(
1582
1794
  const requestedSetterName = `set${inflection.camelize(resolvedAttributeName)}`
1583
1795
  const setterName = receiverClass.findMemberNameInsensitive(receiver, requestedSetterName) || requestedSetterName
1584
1796
  const resourceSetterName = `set${inflection.camelize(attributeName)}Attribute`
1585
- const resourceSetter = resource ? virtualSetterLookup(resource)[resourceSetterName] : undefined
1797
+ const resourceSetter = resource?.resourceMethod(resourceSetterName)
1586
1798
 
1587
1799
  if (setterName in receiver) {
1588
1800
  writableAttributes[attributeName] = value
1589
- } else if (typeof resourceSetter === "function") {
1801
+ } else if (resourceSetter) {
1590
1802
  writableAttributes[attributeName] = value
1591
1803
  } else if (translatedSet.has(attributeName)) {
1592
1804
  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
 
@@ -58,7 +58,15 @@ import {readPayloadAssociationCount, readPayloadComputedAbility, readPayloadQuer
58
58
  */
59
59
  /**
60
60
  * Defines this typedef.
61
- * @typedef {{attributes?: Array<string | FrontendModelAttributeDefinition> | Record<string, FrontendModelAttributeDefinition>, builtInCollectionCommands?: string[], builtInMemberCommands?: string[], collectionCommands?: string[], commands?: string[], memberCommands?: string[], attachments?: Record<string, FrontendModelAttachmentDefinition>, modelName?: string, nestedAttributes?: Record<string, {allowDestroy?: boolean, limit?: number}>, primaryKey?: string, relationships?: string[]}} FrontendModelResourceConfig
61
+ * @typedef {Record<string, FrontendModelTransportValue>} FrontendModelSyncMetadata
62
+ */
63
+ /**
64
+ * Defines this typedef.
65
+ * @typedef {{enabled: boolean, operations: string[], policyHash: string, policyVersion: string | null, metadata?: FrontendModelSyncMetadata}} FrontendModelSyncConfig
66
+ */
67
+ /**
68
+ * Defines this typedef.
69
+ * @typedef {{attributes?: Array<string | FrontendModelAttributeDefinition> | Record<string, FrontendModelAttributeDefinition>, builtInCollectionCommands?: string[], builtInMemberCommands?: string[], collectionCommands?: string[], commands?: string[], memberCommands?: string[], attachments?: Record<string, FrontendModelAttachmentDefinition>, modelName?: string, nestedAttributes?: Record<string, {allowDestroy?: boolean, limit?: number}>, primaryKey?: string, relationships?: string[], sync?: FrontendModelSyncConfig}} FrontendModelResourceConfig
62
70
  */
63
71
  /**
64
72
  * Frontend model constructor type.
@@ -104,6 +112,7 @@ import {readPayloadAssociationCount, readPayloadComputedAbility, readPayloadQuer
104
112
  * @property {Record<string, string> | (() => Record<string, string>)} [requestHeaders] - Extra HTTP/WS headers to attach to every frontend-model API request. Pass a function to compute them at request time (for example to include the current locale).
105
113
  * @property {{get: () => string | null | undefined | Promise<string | null | undefined>, set: (sessionId: string) => void | Promise<void>, clear: () => void | Promise<void>}} [sessionStore] - Optional sessionId persistence hook forwarded to the internal `VelociousWebsocketClient` so WS sessions can be resumed across page reloads / app restarts.
106
114
  * @property {string | (() => string | null | undefined)} [timeZone] - IANA timezone sent with every frontend-model API request for timezone-less datetime parsing.
115
+ * @property {{actorDeviceId: string, actorUserId: string, clientMutationId?: () => string, enabled?: boolean, mutationLog: import("../sync/local-mutation-log.js").default, now?: () => Date, offlineGrant: {id: string}}} [offlineSync] - Offline mutation queue configuration.
107
116
  */
108
117
  /**
109
118
  * FrontendModelIdleWaitArgs type.
@@ -777,6 +786,89 @@ function frontendModelClassFor(model) {
777
786
  return /** @type {FrontendModelClass} */ (constructorValue)
778
787
  }
779
788
 
789
+ /**
790
+ * Whether the configured offline queue should handle a model operation.
791
+ * @param {FrontendModelClass} ModelClass - Model class.
792
+ * @param {"create" | "update" | "destroy"} operation - Sync operation.
793
+ * @returns {boolean} - Whether to queue locally.
794
+ */
795
+ function shouldQueueFrontendModelOperationOffline(ModelClass, operation) {
796
+ const offlineSync = frontendModelTransportConfig.offlineSync
797
+
798
+ if (!offlineSync?.enabled) return false
799
+
800
+ const syncConfig = ModelClass.resourceConfig().sync
801
+
802
+ if (!syncConfig?.enabled) return false
803
+ if (!syncConfig.operations.includes(operation)) throw new Error(`Offline sync for ${ModelClass.getModelName()} does not allow ${operation}`)
804
+
805
+ return true
806
+ }
807
+
808
+ /**
809
+ * Queues an offline sync mutation.
810
+ * @param {object} args - Arguments.
811
+ * @param {Record<string, FrontendModelAttributeValue>} args.attributes - Mutation attributes.
812
+ * @param {string} [args.clientMutationId] - Pre-generated mutation id.
813
+ * @param {FrontendModelClass} args.ModelClass - Model class.
814
+ * @param {"create" | "update" | "destroy"} args.operation - Sync operation.
815
+ * @returns {Promise<string>} - Client mutation id.
816
+ */
817
+ async function queueFrontendModelMutationOffline({attributes, clientMutationId: providedClientMutationId, ModelClass, operation}) {
818
+ const offlineSync = frontendModelTransportConfig.offlineSync
819
+
820
+ if (!offlineSync) throw new Error("Offline sync is not configured")
821
+
822
+ const syncConfig = ModelClass.resourceConfig().sync
823
+ if (!syncConfig?.enabled) throw new Error(`Offline sync is not enabled for ${ModelClass.getModelName()}`)
824
+
825
+ const now = offlineSync.now ? offlineSync.now() : new Date()
826
+ if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new Error("offlineSync.now must return a valid Date")
827
+
828
+ const clientMutationId = providedClientMutationId || (offlineSync.clientMutationId ? offlineSync.clientMutationId() : frontendModelOfflineMutationId())
829
+ if (typeof clientMutationId !== "string" || clientMutationId.length < 1) throw new Error("offlineSync.clientMutationId must return a non-empty string")
830
+
831
+ await offlineSync.mutationLog.append({
832
+ mutation: {
833
+ actorDeviceId: offlineSync.actorDeviceId,
834
+ actorUserId: offlineSync.actorUserId,
835
+ attributes: frontendModelSyncJsonObject(attributes),
836
+ baseVersion: null,
837
+ clientMutationId,
838
+ model: ModelClass.getModelName(),
839
+ occurredAt: now.toISOString(),
840
+ offlineGrantId: offlineSync.offlineGrant.id,
841
+ operation,
842
+ policyHash: syncConfig.policyHash
843
+ }
844
+ })
845
+
846
+ return clientMutationId
847
+ }
848
+
849
+ /**
850
+ * Generates a frontend-model offline mutation id.
851
+ * @returns {string} - Local mutation id.
852
+ */
853
+ function frontendModelOfflineMutationId() {
854
+ if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") return globalThis.crypto.randomUUID()
855
+
856
+ return `frontend-mutation-${Date.now()}-${Math.random().toString(16).slice(2)}`
857
+ }
858
+
859
+ /**
860
+ * Converts model attributes to sync-safe JSON payload values.
861
+ * @param {Record<string, FrontendModelAttributeValue>} attributes - Frontend model attributes.
862
+ * @returns {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} - Sync-safe attributes.
863
+ */
864
+ function frontendModelSyncJsonObject(attributes) {
865
+ const serialized = JSON.parse(JSON.stringify(attributes))
866
+
867
+ if (!serialized || typeof serialized !== "object" || Array.isArray(serialized)) throw new Error("Expected sync mutation attributes object")
868
+
869
+ return /** @type {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} */ (serialized)
870
+ }
871
+
780
872
  /**
781
873
  * Runs normalize frontend attachment input.
782
874
  * @param {?} input - Attachment input.
@@ -2779,6 +2871,10 @@ export default class FrontendModelBase {
2779
2871
  // Reset cached internal client so the new sessionStore is picked up.
2780
2872
  internalWebsocketClient = null
2781
2873
  }
2874
+
2875
+ if (Object.prototype.hasOwnProperty.call(config, "offlineSync")) {
2876
+ frontendModelTransportConfig.offlineSync = config.offlineSync
2877
+ }
2782
2878
  }
2783
2879
 
2784
2880
  /**
@@ -3892,6 +3988,43 @@ export default class FrontendModelBase {
3892
3988
  payload.attachments = attachments
3893
3989
  }
3894
3990
 
3991
+ if (shouldQueueFrontendModelOperationOffline(ModelClass, commandType)) {
3992
+ const offlineAttributes = {...payload.attributes}
3993
+ let clientMutationId
3994
+
3995
+ if (isNew) {
3996
+ const primaryKey = ModelClass.primaryKey()
3997
+ const currentPrimaryKey = this.readAttribute(primaryKey)
3998
+
3999
+ if (currentPrimaryKey === undefined || currentPrimaryKey === null) {
4000
+ clientMutationId = frontendModelTransportConfig.offlineSync?.clientMutationId
4001
+ ? frontendModelTransportConfig.offlineSync.clientMutationId()
4002
+ : frontendModelOfflineMutationId()
4003
+ this.setAttribute(primaryKey, clientMutationId)
4004
+ offlineAttributes[primaryKey] = clientMutationId
4005
+ }
4006
+ } else {
4007
+ offlineAttributes[ModelClass.primaryKey()] = payload.id
4008
+ }
4009
+
4010
+ if (payload.nestedAttributes !== undefined || payload.attachments !== undefined) {
4011
+ throw new Error(`Offline sync for ${ModelClass.name} does not support nested attributes or attachments yet`)
4012
+ }
4013
+
4014
+ await queueFrontendModelMutationOffline({
4015
+ attributes: offlineAttributes,
4016
+ clientMutationId,
4017
+ ModelClass,
4018
+ operation: commandType
4019
+ })
4020
+ this.setIsNewRecord(false)
4021
+ this._persistedAttributes = cloneFrontendModelAttributes(this.attributes())
4022
+ this._pendingNestedAttributes = {}
4023
+ this._clearPendingAttachments()
4024
+
4025
+ return this
4026
+ }
4027
+
3895
4028
  const response = await ModelClass.executeCommand(commandType, payload)
3896
4029
 
3897
4030
  this.assignAttributes(ModelClass.attributesFromResponse(response))
@@ -3944,9 +4077,20 @@ export default class FrontendModelBase {
3944
4077
  */
3945
4078
  async destroy() {
3946
4079
  const ModelClass = frontendModelClassFor(this)
4080
+ const id = this.primaryKeyValue()
4081
+
4082
+ if (shouldQueueFrontendModelOperationOffline(ModelClass, "destroy")) {
4083
+ await queueFrontendModelMutationOffline({
4084
+ attributes: {[ModelClass.primaryKey()]: id},
4085
+ ModelClass,
4086
+ operation: "destroy"
4087
+ })
4088
+
4089
+ return
4090
+ }
3947
4091
 
3948
4092
  await ModelClass.executeCommand("destroy", {
3949
- id: this.primaryKeyValue()
4093
+ id
3950
4094
  })
3951
4095
  }
3952
4096