velocious 1.0.474 → 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 (85) hide show
  1. package/README.md +7 -2
  2. package/build/configuration-types.js +38 -1
  3. package/build/configuration.js +41 -1
  4. package/build/database/drivers/base.js +51 -3
  5. package/build/database/drivers/mysql/index.js +74 -7
  6. package/build/database/drivers/sqlite/connection-sql-js.js +16 -5
  7. package/build/database/drivers/sqlite/index.web.js +29 -15
  8. package/build/database/drivers/sqlite/web-persistence.js +521 -0
  9. package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +63 -0
  10. package/build/frontend-model-controller.js +127 -4
  11. package/build/frontend-model-resource/base-resource.js +5 -1
  12. package/build/frontend-models/base.js +146 -2
  13. package/build/frontend-models/query.js +3 -3
  14. package/build/frontend-models/resource-definition.js +304 -2
  15. package/build/routes/hooks/frontend-model-command-route-hook.js +8 -0
  16. package/build/src/configuration-types.d.ts +113 -2
  17. package/build/src/configuration-types.d.ts.map +1 -1
  18. package/build/src/configuration-types.js +35 -2
  19. package/build/src/configuration.d.ts +18 -1
  20. package/build/src/configuration.d.ts.map +1 -1
  21. package/build/src/configuration.js +37 -2
  22. package/build/src/database/drivers/base.d.ts +30 -0
  23. package/build/src/database/drivers/base.d.ts.map +1 -1
  24. package/build/src/database/drivers/base.js +47 -4
  25. package/build/src/database/drivers/mysql/index.d.ts +32 -3
  26. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  27. package/build/src/database/drivers/mysql/index.js +65 -7
  28. package/build/src/database/drivers/sqlite/connection-sql-js.d.ts +8 -1
  29. package/build/src/database/drivers/sqlite/connection-sql-js.d.ts.map +1 -1
  30. package/build/src/database/drivers/sqlite/connection-sql-js.js +15 -6
  31. package/build/src/database/drivers/sqlite/index.web.d.ts +5 -5
  32. package/build/src/database/drivers/sqlite/index.web.d.ts.map +1 -1
  33. package/build/src/database/drivers/sqlite/index.web.js +27 -13
  34. package/build/src/database/drivers/sqlite/web-persistence.d.ts +77 -0
  35. package/build/src/database/drivers/sqlite/web-persistence.d.ts.map +1 -0
  36. package/build/src/database/drivers/sqlite/web-persistence.js +464 -0
  37. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts +30 -0
  38. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
  39. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +53 -1
  40. package/build/src/frontend-model-controller.d.ts +34 -0
  41. package/build/src/frontend-model-controller.d.ts.map +1 -1
  42. package/build/src/frontend-model-controller.js +113 -5
  43. package/build/src/frontend-model-resource/base-resource.d.ts +4 -2
  44. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  45. package/build/src/frontend-model-resource/base-resource.js +7 -2
  46. package/build/src/frontend-models/base.d.ts +29 -0
  47. package/build/src/frontend-models/base.d.ts.map +1 -1
  48. package/build/src/frontend-models/base.js +131 -3
  49. package/build/src/frontend-models/query.js +4 -4
  50. package/build/src/frontend-models/resource-definition.d.ts +6 -0
  51. package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
  52. package/build/src/frontend-models/resource-definition.js +272 -3
  53. package/build/src/routes/hooks/frontend-model-command-route-hook.d.ts.map +1 -1
  54. package/build/src/routes/hooks/frontend-model-command-route-hook.js +8 -1
  55. package/build/src/sync/device-identity.d.ts +240 -0
  56. package/build/src/sync/device-identity.d.ts.map +1 -0
  57. package/build/src/sync/device-identity.js +454 -0
  58. package/build/src/sync/local-mutation-log.d.ts +212 -0
  59. package/build/src/sync/local-mutation-log.d.ts.map +1 -0
  60. package/build/src/sync/local-mutation-log.js +404 -0
  61. package/build/src/sync/offline-grant.d.ts +157 -0
  62. package/build/src/sync/offline-grant.d.ts.map +1 -0
  63. package/build/src/sync/offline-grant.js +292 -0
  64. package/build/sync/device-identity.js +503 -0
  65. package/build/sync/local-mutation-log.js +448 -0
  66. package/build/sync/offline-grant.js +327 -0
  67. package/build/tsconfig.tsbuildinfo +1 -1
  68. package/package.json +3 -3
  69. package/src/configuration-types.js +38 -1
  70. package/src/configuration.js +41 -1
  71. package/src/database/drivers/base.js +51 -3
  72. package/src/database/drivers/mysql/index.js +74 -7
  73. package/src/database/drivers/sqlite/connection-sql-js.js +16 -5
  74. package/src/database/drivers/sqlite/index.web.js +29 -15
  75. package/src/database/drivers/sqlite/web-persistence.js +521 -0
  76. package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +63 -0
  77. package/src/frontend-model-controller.js +127 -4
  78. package/src/frontend-model-resource/base-resource.js +5 -1
  79. package/src/frontend-models/base.js +146 -2
  80. package/src/frontend-models/query.js +3 -3
  81. package/src/frontend-models/resource-definition.js +304 -2
  82. package/src/routes/hooks/frontend-model-command-route-hook.js +8 -0
  83. package/src/sync/device-identity.js +503 -0
  84. package/src/sync/local-mutation-log.js +448 -0
  85. package/src/sync/offline-grant.js +327 -0
@@ -5,7 +5,8 @@ import Controller from "./controller.js"
5
5
  import FrontendModelBaseResource from "./frontend-model-resource/base-resource.js"
6
6
  import Response from "./http-server/client/response.js"
7
7
  import {frontendModelResourcesWithBuiltInsForBackendProject} from "./frontend-models/built-in-resources.js"
8
- import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition, frontendModelResourcePath, frontendModelResourcesForBackendProject} from "./frontend-models/resource-definition.js"
8
+ import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition, frontendModelResourcePath, frontendModelResourcesForBackendProject, frontendModelSyncManifestForBackendProjects} from "./frontend-models/resource-definition.js"
9
+ import {createOfflineGrantFromBootstrap} from "./sync/offline-grant.js"
9
10
  import {FrontendModelQueryError, normalizeGroup as normalizeQueryGroup, normalizeJoins as normalizeQueryJoins, normalizePluck as normalizeQueryPluck, normalizePreload as normalizeQueryPreload, normalizeSearchOperator as normalizeQuerySearchOperator, normalizeSort as normalizeQuerySort} from "./frontend-models/query.js"
10
11
  import {assignSafeProperty, deserializeFrontendModelTransportValue, isBackendModelInstance, serializeFrontendModelTransportValue} from "./frontend-models/transport-serialization.js"
11
12
  import {requestDetails} from "./error-reporting/request-details.js"
@@ -25,7 +26,11 @@ import {RansackQueryError, normalizeRansackGroup, parseRansackSort} from "./util
25
26
  function normalizeFrontendModelPreload(preload) {
26
27
  if (!preload) return null
27
28
 
28
- return normalizeQueryPreload(preload)
29
+ try {
30
+ return normalizeQueryPreload(preload)
31
+ } catch (error) {
32
+ throwFrontendModelQueryErrorForParserError(error)
33
+ }
29
34
  }
30
35
 
31
36
  /**
@@ -863,13 +868,31 @@ export default class FrontendModelController extends Controller {
863
868
  for (const [relationshipName, relationshipPreload] of Object.entries(preload)) {
864
869
  if (relationshipPreload === false) continue
865
870
 
866
- const relationship = modelClass.getRelationshipByName(relationshipName)
871
+ const relationship = modelClass.getRelationshipsMap()[relationshipName]
872
+ if (!relationship) {
873
+ throw frontendModelQueryError(`Unknown preload relationship "${relationshipName}" for ${modelClass.name}`)
874
+ }
875
+
867
876
  const targetModelClass = await this.ensureFrontendModelRelationshipTargetClassInitialized({
868
877
  backendProject,
869
878
  relationship
870
879
  })
871
880
 
872
- if (!targetModelClass || !isPlainObject(relationshipPreload)) continue
881
+ if (!targetModelClass) {
882
+ if (isPlainObject(relationshipPreload) && Object.keys(relationshipPreload).length > 0) {
883
+ let message = `Cannot preload nested relationships through relationship "${relationshipName}" for ${modelClass.name} because its target model class could not be resolved`
884
+
885
+ if (relationship.getPolymorphic() && relationship.getType() === "belongsTo") {
886
+ message = `Cannot preload nested relationships through polymorphic relationship "${relationshipName}" for ${modelClass.name}`
887
+ }
888
+
889
+ throw frontendModelQueryError(message)
890
+ }
891
+
892
+ continue
893
+ }
894
+
895
+ if (!isPlainObject(relationshipPreload)) continue
873
896
 
874
897
  await this.ensureFrontendModelPreloadClassesInitialized({
875
898
  backendProject,
@@ -3438,6 +3461,106 @@ export default class FrontendModelController extends Controller {
3438
3461
  return {status: "success"}
3439
3462
  }
3440
3463
 
3464
+ /**
3465
+ * Runs frontend sync bootstrap.
3466
+ * @returns {Promise<void>} - Sync bootstrap response with manifest and signed offline grant.
3467
+ */
3468
+ async frontendSyncBootstrap() {
3469
+ if (this.request().httpMethod() === "OPTIONS") {
3470
+ await this.render({status: 204, json: {}})
3471
+ return
3472
+ }
3473
+
3474
+ const params = /** @type {Record<string, import("./configuration-types.js").FrontendModelSyncJsonValue | undefined>} */ (deserializeFrontendModelTransportValue(this.params()))
3475
+ const configuration = this.getConfiguration()
3476
+ const syncManifest = frontendModelSyncManifestForBackendProjects(configuration.getBackendProjects())
3477
+ const offlineGrant = await createOfflineGrantFromBootstrap({
3478
+ deviceId: this.frontendSyncBootstrapDeviceId(params),
3479
+ grantId: this.frontendSyncBootstrapGrantId(params),
3480
+ grantTtlMs: configuration.getSyncConfiguration().offlineGrantTtlMs,
3481
+ now: this.frontendSyncBootstrapNow(params),
3482
+ resources: syncManifest,
3483
+ scopes: this.frontendSyncBootstrapScopes(params),
3484
+ signingKey: configuration.currentOfflineGrantSigningKey(),
3485
+ userId: this.frontendSyncBootstrapUserId()
3486
+ })
3487
+
3488
+ await this.render({
3489
+ json: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue({
3490
+ offlineGrant,
3491
+ status: "success",
3492
+ syncManifest
3493
+ }, this.transportSerializationOptions()))
3494
+ })
3495
+ }
3496
+
3497
+ /**
3498
+ * Resolves device id for sync bootstrap.
3499
+ * @param {Record<string, import("./configuration-types.js").FrontendModelSyncJsonValue | undefined>} params - Request params.
3500
+ * @returns {string} - Device id.
3501
+ */
3502
+ frontendSyncBootstrapDeviceId(params) {
3503
+ if (typeof params.deviceId === "string" && params.deviceId.length > 0) return params.deviceId
3504
+
3505
+ throw new Error("Expected sync bootstrap deviceId")
3506
+ }
3507
+
3508
+ /**
3509
+ * Resolves grant id for sync bootstrap.
3510
+ * @param {Record<string, import("./configuration-types.js").FrontendModelSyncJsonValue | undefined>} params - Request params.
3511
+ * @returns {string | undefined} - Deterministic grant id for tests, generated id otherwise.
3512
+ */
3513
+ frontendSyncBootstrapGrantId(params) {
3514
+ if (this.getConfiguration().getEnvironment() === "test" && typeof params.grantId === "string") return params.grantId
3515
+
3516
+ return undefined
3517
+ }
3518
+
3519
+ /**
3520
+ * Resolves bootstrap issue time.
3521
+ * @param {Record<string, import("./configuration-types.js").FrontendModelSyncJsonValue | undefined>} params - Request params.
3522
+ * @returns {Date} - Issue time.
3523
+ */
3524
+ frontendSyncBootstrapNow(params) {
3525
+ if (this.getConfiguration().getEnvironment() === "test" && typeof params.now === "string") return new Date(params.now)
3526
+
3527
+ return new Date()
3528
+ }
3529
+
3530
+ /**
3531
+ * Resolves sync bootstrap scopes.
3532
+ * @param {Record<string, import("./configuration-types.js").FrontendModelSyncJsonValue | undefined>} params - Request params.
3533
+ * @returns {Record<string, import("./configuration-types.js").FrontendModelSyncJsonValue>} - Grant scopes.
3534
+ */
3535
+ frontendSyncBootstrapScopes(params) {
3536
+ const scopes = params.scopes
3537
+
3538
+ if (scopes && typeof scopes === "object" && !Array.isArray(scopes)) {
3539
+ return /** @type {Record<string, import("./configuration-types.js").FrontendModelSyncJsonValue>} */ (scopes)
3540
+ }
3541
+
3542
+ return {}
3543
+ }
3544
+
3545
+ /**
3546
+ * Resolves current user id for sync bootstrap.
3547
+ * @returns {string} - User id.
3548
+ */
3549
+ frontendSyncBootstrapUserId() {
3550
+ const ability = this.currentAbility()
3551
+ const currentUser = ability?.currentUser()
3552
+
3553
+ if (typeof currentUser === "string" || typeof currentUser === "number") return String(currentUser)
3554
+ if (currentUser && typeof currentUser === "object") {
3555
+ const userRecord = /** @type {{id?: string | number | (() => string | number)}} */ (currentUser)
3556
+ const idValue = typeof userRecord.id === "function" ? userRecord.id() : userRecord.id
3557
+
3558
+ if (typeof idValue === "string" || typeof idValue === "number") return String(idValue)
3559
+ }
3560
+
3561
+ throw new Error("Expected sync bootstrap current user")
3562
+ }
3563
+
3441
3564
  /**
3442
3565
  * Runs frontend api.
3443
3566
  * @returns {Promise<void>} - Shared frontend model API action with batch support.
@@ -165,6 +165,8 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
165
165
  static primaryKey = undefined
166
166
  /** @type {import("../configuration-types.js").FrontendModelResourceServerConfiguration | undefined} */
167
167
  static server = undefined
168
+ /** @type {import("../configuration-types.js").FrontendModelResourceSyncConfiguration | boolean | undefined} */
169
+ static sync = undefined
168
170
  /** @type {string[] | undefined} */
169
171
  static translatedAttributes = undefined
170
172
  /** @type {?} */
@@ -204,7 +206,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
204
206
  /**
205
207
  * Reads a static resource config value from the environment resource first,
206
208
  * 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.
209
+ * @param {"abilities" | "attachments" | "attributes" | "builtInCollectionCommands" | "builtInMemberCommands" | "collectionCommands" | "commands" | "memberCommands" | "modelName" | "primaryKey" | "relationships" | "server" | "sync" | "translatedAttributes"} name - Static config property name.
208
210
  * @returns {?} - Resolved config value.
209
211
  */
210
212
  static sharedResourceStaticValue(name) {
@@ -359,6 +361,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
359
361
  const primaryKey = this.sharedResourceStaticValue("primaryKey")
360
362
  const relationships = this.sharedResourceStaticValue("relationships")
361
363
  const server = this.sharedResourceStaticValue("server")
364
+ const sync = this.sharedResourceStaticValue("sync")
362
365
  /** @type {import("../configuration-types.js").FrontendModelResourceConfiguration} */
363
366
  const config = {
364
367
  attributes: /** @type {Record<string, ?> | string[]} */ (attributes || [])
@@ -375,6 +378,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
375
378
  if (primaryKey) config.primaryKey = /** @type {string} */ (primaryKey)
376
379
  if (relationships) config.relationships = /** @type {string[]} */ (relationships)
377
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)
378
382
 
379
383
  return config
380
384
  }
@@ -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
 
@@ -129,14 +129,14 @@ export function normalizePreload(preload) {
129
129
  continue
130
130
  }
131
131
 
132
- throw new Error(`Invalid preload entry type: ${typeof entry}`)
132
+ throw frontendModelQueryError(`Invalid preload entry type: ${typeof entry}`)
133
133
  }
134
134
 
135
135
  return normalized
136
136
  }
137
137
 
138
138
  if (!isPlainObject(preload)) {
139
- throw new Error(`Invalid preload type: ${typeof preload}`)
139
+ throw frontendModelQueryError(`Invalid preload type: ${typeof preload}`)
140
140
  }
141
141
 
142
142
  /**
@@ -155,7 +155,7 @@ export function normalizePreload(preload) {
155
155
  continue
156
156
  }
157
157
 
158
- throw new Error(`Invalid preload value for ${relationshipName}: ${typeof relationshipPreload}`)
158
+ throw frontendModelQueryError(`Invalid preload value for ${relationshipName}: ${typeof relationshipPreload}`)
159
159
  }
160
160
 
161
161
  return normalized