velocious 1.0.474 → 1.0.476

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 (141) hide show
  1. package/README.md +10 -2
  2. package/build/configuration-types.js +39 -1
  3. package/build/configuration.js +46 -1
  4. package/build/database/drivers/base.js +67 -3
  5. package/build/database/drivers/mssql/index.js +13 -0
  6. package/build/database/drivers/mssql/sql/remove-index.js +6 -0
  7. package/build/database/drivers/mysql/index.js +87 -7
  8. package/build/database/drivers/mysql/sql/remove-index.js +6 -0
  9. package/build/database/drivers/pgsql/index.js +13 -0
  10. package/build/database/drivers/pgsql/sql/remove-index.js +6 -0
  11. package/build/database/drivers/sqlite/base.js +13 -0
  12. package/build/database/drivers/sqlite/connection-sql-js.js +16 -5
  13. package/build/database/drivers/sqlite/index.web.js +29 -15
  14. package/build/database/drivers/sqlite/sql/remove-index.js +6 -0
  15. package/build/database/drivers/sqlite/web-persistence.js +521 -0
  16. package/build/database/migration/index.js +44 -0
  17. package/build/database/pool/base-methods-forward.js +1 -0
  18. package/build/database/query/remove-index-base.js +39 -0
  19. package/build/database/record/index.js +25 -9
  20. package/build/environment-handlers/node/cli/commands/generate/base-models.js +36 -0
  21. package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +63 -0
  22. package/build/frontend-model-controller.js +401 -4
  23. package/build/frontend-model-resource/base-resource.js +5 -1
  24. package/build/frontend-models/base.js +146 -2
  25. package/build/frontend-models/query.js +3 -3
  26. package/build/frontend-models/resource-definition.js +304 -2
  27. package/build/routes/hooks/frontend-model-command-route-hook.js +16 -0
  28. package/build/src/configuration-types.d.ts +118 -2
  29. package/build/src/configuration-types.d.ts.map +1 -1
  30. package/build/src/configuration-types.js +36 -2
  31. package/build/src/configuration.d.ts +18 -1
  32. package/build/src/configuration.d.ts.map +1 -1
  33. package/build/src/configuration.js +42 -2
  34. package/build/src/database/drivers/base.d.ts +50 -0
  35. package/build/src/database/drivers/base.d.ts.map +1 -1
  36. package/build/src/database/drivers/base.js +62 -4
  37. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  38. package/build/src/database/drivers/mssql/index.js +12 -1
  39. package/build/src/database/drivers/mssql/sql/remove-index.d.ts +4 -0
  40. package/build/src/database/drivers/mssql/sql/remove-index.d.ts.map +1 -0
  41. package/build/src/database/drivers/mssql/sql/remove-index.js +5 -0
  42. package/build/src/database/drivers/mysql/index.d.ts +32 -3
  43. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  44. package/build/src/database/drivers/mysql/index.js +76 -7
  45. package/build/src/database/drivers/mysql/sql/remove-index.d.ts +4 -0
  46. package/build/src/database/drivers/mysql/sql/remove-index.d.ts.map +1 -0
  47. package/build/src/database/drivers/mysql/sql/remove-index.js +5 -0
  48. package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
  49. package/build/src/database/drivers/pgsql/index.js +12 -1
  50. package/build/src/database/drivers/pgsql/sql/remove-index.d.ts +4 -0
  51. package/build/src/database/drivers/pgsql/sql/remove-index.d.ts.map +1 -0
  52. package/build/src/database/drivers/pgsql/sql/remove-index.js +5 -0
  53. package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
  54. package/build/src/database/drivers/sqlite/base.js +12 -1
  55. package/build/src/database/drivers/sqlite/connection-sql-js.d.ts +8 -1
  56. package/build/src/database/drivers/sqlite/connection-sql-js.d.ts.map +1 -1
  57. package/build/src/database/drivers/sqlite/connection-sql-js.js +15 -6
  58. package/build/src/database/drivers/sqlite/index.web.d.ts +5 -5
  59. package/build/src/database/drivers/sqlite/index.web.d.ts.map +1 -1
  60. package/build/src/database/drivers/sqlite/index.web.js +27 -13
  61. package/build/src/database/drivers/sqlite/sql/remove-index.d.ts +4 -0
  62. package/build/src/database/drivers/sqlite/sql/remove-index.d.ts.map +1 -0
  63. package/build/src/database/drivers/sqlite/sql/remove-index.js +5 -0
  64. package/build/src/database/drivers/sqlite/web-persistence.d.ts +77 -0
  65. package/build/src/database/drivers/sqlite/web-persistence.d.ts.map +1 -0
  66. package/build/src/database/drivers/sqlite/web-persistence.js +464 -0
  67. package/build/src/database/migration/index.d.ts +25 -0
  68. package/build/src/database/migration/index.d.ts.map +1 -1
  69. package/build/src/database/migration/index.js +39 -1
  70. package/build/src/database/pool/base-methods-forward.d.ts.map +1 -1
  71. package/build/src/database/pool/base-methods-forward.js +2 -1
  72. package/build/src/database/query/remove-index-base.d.ts +35 -0
  73. package/build/src/database/query/remove-index-base.d.ts.map +1 -0
  74. package/build/src/database/query/remove-index-base.js +34 -0
  75. package/build/src/database/record/index.d.ts +11 -0
  76. package/build/src/database/record/index.d.ts.map +1 -1
  77. package/build/src/database/record/index.js +26 -11
  78. package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts +15 -0
  79. package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts.map +1 -1
  80. package/build/src/environment-handlers/node/cli/commands/generate/base-models.js +33 -1
  81. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts +30 -0
  82. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
  83. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +53 -1
  84. package/build/src/frontend-model-controller.d.ts +96 -0
  85. package/build/src/frontend-model-controller.d.ts.map +1 -1
  86. package/build/src/frontend-model-controller.js +359 -5
  87. package/build/src/frontend-model-resource/base-resource.d.ts +4 -2
  88. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  89. package/build/src/frontend-model-resource/base-resource.js +7 -2
  90. package/build/src/frontend-models/base.d.ts +29 -0
  91. package/build/src/frontend-models/base.d.ts.map +1 -1
  92. package/build/src/frontend-models/base.js +131 -3
  93. package/build/src/frontend-models/query.js +4 -4
  94. package/build/src/frontend-models/resource-definition.d.ts +6 -0
  95. package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
  96. package/build/src/frontend-models/resource-definition.js +272 -3
  97. package/build/src/routes/hooks/frontend-model-command-route-hook.d.ts.map +1 -1
  98. package/build/src/routes/hooks/frontend-model-command-route-hook.js +15 -1
  99. package/build/src/sync/device-identity.d.ts +240 -0
  100. package/build/src/sync/device-identity.d.ts.map +1 -0
  101. package/build/src/sync/device-identity.js +454 -0
  102. package/build/src/sync/local-mutation-log.d.ts +212 -0
  103. package/build/src/sync/local-mutation-log.d.ts.map +1 -0
  104. package/build/src/sync/local-mutation-log.js +404 -0
  105. package/build/src/sync/offline-grant.d.ts +157 -0
  106. package/build/src/sync/offline-grant.d.ts.map +1 -0
  107. package/build/src/sync/offline-grant.js +292 -0
  108. package/build/sync/device-identity.js +503 -0
  109. package/build/sync/local-mutation-log.js +448 -0
  110. package/build/sync/offline-grant.js +327 -0
  111. package/build/tsconfig.tsbuildinfo +1 -1
  112. package/package.json +3 -3
  113. package/src/configuration-types.js +39 -1
  114. package/src/configuration.js +46 -1
  115. package/src/database/drivers/base.js +67 -3
  116. package/src/database/drivers/mssql/index.js +13 -0
  117. package/src/database/drivers/mssql/sql/remove-index.js +6 -0
  118. package/src/database/drivers/mysql/index.js +87 -7
  119. package/src/database/drivers/mysql/sql/remove-index.js +6 -0
  120. package/src/database/drivers/pgsql/index.js +13 -0
  121. package/src/database/drivers/pgsql/sql/remove-index.js +6 -0
  122. package/src/database/drivers/sqlite/base.js +13 -0
  123. package/src/database/drivers/sqlite/connection-sql-js.js +16 -5
  124. package/src/database/drivers/sqlite/index.web.js +29 -15
  125. package/src/database/drivers/sqlite/sql/remove-index.js +6 -0
  126. package/src/database/drivers/sqlite/web-persistence.js +521 -0
  127. package/src/database/migration/index.js +44 -0
  128. package/src/database/pool/base-methods-forward.js +1 -0
  129. package/src/database/query/remove-index-base.js +39 -0
  130. package/src/database/record/index.js +25 -9
  131. package/src/environment-handlers/node/cli/commands/generate/base-models.js +36 -0
  132. package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +63 -0
  133. package/src/frontend-model-controller.js +401 -4
  134. package/src/frontend-model-resource/base-resource.js +5 -1
  135. package/src/frontend-models/base.js +146 -2
  136. package/src/frontend-models/query.js +3 -3
  137. package/src/frontend-models/resource-definition.js +304 -2
  138. package/src/routes/hooks/frontend-model-command-route-hook.js +16 -0
  139. package/src/sync/device-identity.js +503 -0
  140. package/src/sync/local-mutation-log.js +448 -0
  141. package/src/sync/offline-grant.js +327 -0
@@ -120,6 +120,7 @@ export default class DbGenerateModel extends BaseCommand {
120
120
 
121
121
  const columns = await table.getColumns()
122
122
  const writeAttributeTypeName = `${modelNameCamelized}WriteAttributes`
123
+ const belongsToWriteAttributes = await this.belongsToWriteAttributesForModel({modelClass, modelsDir})
123
124
  const nestedWriteAttributes = this.nestedWriteAttributesForModel({modelClass})
124
125
 
125
126
  fileContent += `import DatabaseRecord from "${velociousPath}/database/record/index.js"\n\n`
@@ -135,6 +136,9 @@ export default class DbGenerateModel extends BaseCommand {
135
136
  fileContent += ` * @property {${setterJsdocType}${column.getNull() ? " | null" : ""}} [${camelizedColumnName}] - Value for the ${camelizedColumnName} attribute.\n`
136
137
  }
137
138
  }
139
+ for (const belongsToWriteAttribute of belongsToWriteAttributes) {
140
+ fileContent += ` * @property {${belongsToWriteAttribute.propertyType}} [${belongsToWriteAttribute.propertyName}] - Related ${belongsToWriteAttribute.relationshipName} record.\n`
141
+ }
138
142
  for (const nestedWriteAttribute of nestedWriteAttributes) {
139
143
  fileContent += ` * @property {${nestedWriteAttribute.propertyType}} [${nestedWriteAttribute.propertyName}] - Nested ${nestedWriteAttribute.relationshipName} attributes.\n`
140
144
  }
@@ -440,6 +444,38 @@ export default class DbGenerateModel extends BaseCommand {
440
444
  return this.jsDocTypeFromColumn(column, modelClass)
441
445
  }
442
446
 
447
+ /**
448
+ * Runs belongs to write attributes for model.
449
+ * @param {object} args - Arguments.
450
+ * @param {typeof import("../../../../../database/record/index.js").default} args.modelClass - Model class.
451
+ * @param {string} args.modelsDir - Source models directory.
452
+ * @returns {Promise<Array<{propertyName: string, propertyType: string, relationshipName: string}>>} - Belongs-to write attributes.
453
+ */
454
+ async belongsToWriteAttributesForModel({modelClass, modelsDir}) {
455
+ const writeAttributes = []
456
+
457
+ for (const relationship of modelClass.getRelationships()) {
458
+ if (relationship.getType() !== "belongsTo") continue
459
+ if (relationship.getPolymorphic()) continue
460
+
461
+ const targetModelClass = relationship.getTargetModelClass()
462
+
463
+ if (!targetModelClass) throw new Error(`Relationship '${relationship.getRelationshipName()}' on '${modelClass.getModelName()}' has no target model class`)
464
+
465
+ const targetModelFileName = inflection.dasherize(inflection.underscore(targetModelClass.getModelName()))
466
+ const targetModelPath = `${modelsDir}/${targetModelFileName}.js`
467
+ const targetImportPath = await fileExists(targetModelPath) ? `../models/${targetModelFileName}.js` : `./${targetModelFileName}.js`
468
+
469
+ writeAttributes.push({
470
+ propertyName: relationship.getRelationshipName(),
471
+ propertyType: `import("${targetImportPath}").default`,
472
+ relationshipName: relationship.getRelationshipName()
473
+ })
474
+ }
475
+
476
+ return writeAttributes
477
+ }
478
+
443
479
  /**
444
480
  * Runs nested write attributes for model.
445
481
  * @param {object} args - Arguments.
@@ -442,6 +442,13 @@ export default class DbGenerateFrontendModels extends BaseCommand {
442
442
  }
443
443
  fileContent += " },\n"
444
444
  }
445
+ if (modelConfig.sync?.enabled) {
446
+ fileContent += this.formattedJsonProperty({
447
+ indent: " ",
448
+ propertyName: "sync",
449
+ value: modelConfig.sync
450
+ })
451
+ }
445
452
  fileContent += " }\n"
446
453
  fileContent += " }\n"
447
454
 
@@ -999,6 +1006,62 @@ export default class DbGenerateFrontendModels extends BaseCommand {
999
1006
  return output
1000
1007
  }
1001
1008
 
1009
+ /**
1010
+ * Runs formatted JSON property.
1011
+ * @param {object} args - Formatting args.
1012
+ * @param {string} args.indent - Base indentation.
1013
+ * @param {string} args.propertyName - Object property name.
1014
+ * @param {unknown} args.value - JSON-compatible value.
1015
+ * @returns {string} - Formatted property.
1016
+ */
1017
+ formattedJsonProperty({indent, propertyName, value}) {
1018
+ return `${indent}${propertyName}: ${this.formattedJsonValue({indent, value})},\n`
1019
+ }
1020
+
1021
+ /**
1022
+ * Runs formatted JSON value.
1023
+ * @param {object} args - Formatting args.
1024
+ * @param {string} args.indent - Indentation before this value.
1025
+ * @param {unknown} args.value - JSON-compatible value.
1026
+ * @returns {string} - Formatted value.
1027
+ */
1028
+ formattedJsonValue({indent, value}) {
1029
+ if (Array.isArray(value)) {
1030
+ let output = "[\n"
1031
+
1032
+ for (const entry of value) {
1033
+ output += `${indent} ${this.formattedJsonValue({indent: `${indent} `, value: entry})},\n`
1034
+ }
1035
+
1036
+ output += `${indent}]`
1037
+
1038
+ return output
1039
+ }
1040
+
1041
+ if (value && typeof value === "object") {
1042
+ let output = "{\n"
1043
+
1044
+ for (const key of Object.keys(value)) {
1045
+ output += `${indent} ${this.formattedObjectKey(key)}: ${this.formattedJsonValue({indent: `${indent} `, value: /** @type {Record<string, unknown>} */ (value)[key]})},\n`
1046
+ }
1047
+
1048
+ output += `${indent}}`
1049
+
1050
+ return output
1051
+ }
1052
+
1053
+ return JSON.stringify(value)
1054
+ }
1055
+
1056
+ /**
1057
+ * Runs formatted object key.
1058
+ * @param {string} key - Object key.
1059
+ * @returns {string} - JavaScript object key.
1060
+ */
1061
+ formattedObjectKey(key) {
1062
+ return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key)
1063
+ }
1064
+
1002
1065
  /**
1003
1066
  * Runs attribute definitions for model.
1004
1067
  * @param {object} args - Arguments.
@@ -5,7 +5,9 @@ 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, verifyOfflineGrant} from "./sync/offline-grant.js"
10
+ import {mutationIdempotencyKey, verifySignedMutation} from "./sync/device-identity.js"
9
11
  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
12
  import {assignSafeProperty, deserializeFrontendModelTransportValue, isBackendModelInstance, serializeFrontendModelTransportValue} from "./frontend-models/transport-serialization.js"
11
13
  import {requestDetails} from "./error-reporting/request-details.js"
@@ -25,7 +27,11 @@ import {RansackQueryError, normalizeRansackGroup, parseRansackSort} from "./util
25
27
  function normalizeFrontendModelPreload(preload) {
26
28
  if (!preload) return null
27
29
 
28
- return normalizeQueryPreload(preload)
30
+ try {
31
+ return normalizeQueryPreload(preload)
32
+ } catch (error) {
33
+ throwFrontendModelQueryErrorForParserError(error)
34
+ }
29
35
  }
30
36
 
31
37
  /**
@@ -167,6 +173,19 @@ const frontendModelWhereNoMatchSymbol = Symbol("frontendModelWhereNoMatch")
167
173
  const frontendModelClientSafeErrorMessage = "Request failed."
168
174
  const frontendModelDebugErrorEnvironments = new Set(["development", "test"])
169
175
 
176
+ /**
177
+ * Builds a client-safe sync replay validation error.
178
+ * @param {string} message - Client-safe validation message.
179
+ * @param {unknown} [cause] - Original cause.
180
+ * @returns {VelociousError} - Client-safe replay error.
181
+ */
182
+ function frontendSyncReplaySafeError(message, cause) {
183
+ return VelociousError.safe(message, {
184
+ cause,
185
+ code: "frontend_sync_replay_error"
186
+ })
187
+ }
188
+
170
189
  /**
171
190
  * Runs frontend model query metadata.
172
191
  * @param {import("./database/query/model-class-query.js").default} query - Query instance.
@@ -863,13 +882,31 @@ export default class FrontendModelController extends Controller {
863
882
  for (const [relationshipName, relationshipPreload] of Object.entries(preload)) {
864
883
  if (relationshipPreload === false) continue
865
884
 
866
- const relationship = modelClass.getRelationshipByName(relationshipName)
885
+ const relationship = modelClass.getRelationshipsMap()[relationshipName]
886
+ if (!relationship) {
887
+ throw frontendModelQueryError(`Unknown preload relationship "${relationshipName}" for ${modelClass.name}`)
888
+ }
889
+
867
890
  const targetModelClass = await this.ensureFrontendModelRelationshipTargetClassInitialized({
868
891
  backendProject,
869
892
  relationship
870
893
  })
871
894
 
872
- if (!targetModelClass || !isPlainObject(relationshipPreload)) continue
895
+ if (!targetModelClass) {
896
+ if (isPlainObject(relationshipPreload) && Object.keys(relationshipPreload).length > 0) {
897
+ let message = `Cannot preload nested relationships through relationship "${relationshipName}" for ${modelClass.name} because its target model class could not be resolved`
898
+
899
+ if (relationship.getPolymorphic() && relationship.getType() === "belongsTo") {
900
+ message = `Cannot preload nested relationships through polymorphic relationship "${relationshipName}" for ${modelClass.name}`
901
+ }
902
+
903
+ throw frontendModelQueryError(message)
904
+ }
905
+
906
+ continue
907
+ }
908
+
909
+ if (!isPlainObject(relationshipPreload)) continue
873
910
 
874
911
  await this.ensureFrontendModelPreloadClassesInitialized({
875
912
  backendProject,
@@ -3438,6 +3475,366 @@ export default class FrontendModelController extends Controller {
3438
3475
  return {status: "success"}
3439
3476
  }
3440
3477
 
3478
+ /**
3479
+ * Runs frontend sync bootstrap.
3480
+ * @returns {Promise<void>} - Sync bootstrap response with manifest and signed offline grant.
3481
+ */
3482
+ async frontendSyncBootstrap() {
3483
+ if (this.request().httpMethod() === "OPTIONS") {
3484
+ await this.render({status: 204, json: {}})
3485
+ return
3486
+ }
3487
+
3488
+ const params = /** @type {Record<string, import("./configuration-types.js").FrontendModelSyncJsonValue | undefined>} */ (deserializeFrontendModelTransportValue(this.params()))
3489
+ const configuration = this.getConfiguration()
3490
+ const syncManifest = frontendModelSyncManifestForBackendProjects(configuration.getBackendProjects())
3491
+ const offlineGrant = await createOfflineGrantFromBootstrap({
3492
+ deviceId: this.frontendSyncBootstrapDeviceId(params),
3493
+ grantId: this.frontendSyncBootstrapGrantId(params),
3494
+ grantTtlMs: configuration.getSyncConfiguration().offlineGrantTtlMs,
3495
+ now: this.frontendSyncBootstrapNow(params),
3496
+ resources: syncManifest,
3497
+ scopes: this.frontendSyncBootstrapScopes(params),
3498
+ signingKey: configuration.currentOfflineGrantSigningKey(),
3499
+ userId: this.frontendSyncBootstrapUserId()
3500
+ })
3501
+
3502
+ await this.render({
3503
+ json: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue({
3504
+ offlineGrant,
3505
+ status: "success",
3506
+ syncManifest
3507
+ }, this.transportSerializationOptions()))
3508
+ })
3509
+ }
3510
+
3511
+ /**
3512
+ * Resolves device id for sync bootstrap.
3513
+ * @param {Record<string, import("./configuration-types.js").FrontendModelSyncJsonValue | undefined>} params - Request params.
3514
+ * @returns {string} - Device id.
3515
+ */
3516
+ frontendSyncBootstrapDeviceId(params) {
3517
+ if (typeof params.deviceId === "string" && params.deviceId.length > 0) return params.deviceId
3518
+
3519
+ throw new Error("Expected sync bootstrap deviceId")
3520
+ }
3521
+
3522
+ /**
3523
+ * Resolves grant id for sync bootstrap.
3524
+ * @param {Record<string, import("./configuration-types.js").FrontendModelSyncJsonValue | undefined>} params - Request params.
3525
+ * @returns {string | undefined} - Deterministic grant id for tests, generated id otherwise.
3526
+ */
3527
+ frontendSyncBootstrapGrantId(params) {
3528
+ if (this.getConfiguration().getEnvironment() === "test" && typeof params.grantId === "string") return params.grantId
3529
+
3530
+ return undefined
3531
+ }
3532
+
3533
+ /**
3534
+ * Resolves bootstrap issue time.
3535
+ * @param {Record<string, import("./configuration-types.js").FrontendModelSyncJsonValue | undefined>} params - Request params.
3536
+ * @returns {Date} - Issue time.
3537
+ */
3538
+ frontendSyncBootstrapNow(params) {
3539
+ if (this.getConfiguration().getEnvironment() === "test" && typeof params.now === "string") return new Date(params.now)
3540
+
3541
+ return new Date()
3542
+ }
3543
+
3544
+ /**
3545
+ * Resolves sync bootstrap scopes.
3546
+ * @param {Record<string, import("./configuration-types.js").FrontendModelSyncJsonValue | undefined>} params - Request params.
3547
+ * @returns {Record<string, import("./configuration-types.js").FrontendModelSyncJsonValue>} - Grant scopes.
3548
+ */
3549
+ frontendSyncBootstrapScopes(params) {
3550
+ const scopes = params.scopes
3551
+
3552
+ if (scopes && typeof scopes === "object" && !Array.isArray(scopes)) {
3553
+ return /** @type {Record<string, import("./configuration-types.js").FrontendModelSyncJsonValue>} */ (scopes)
3554
+ }
3555
+
3556
+ return {}
3557
+ }
3558
+
3559
+ /**
3560
+ * Resolves current user id for sync bootstrap.
3561
+ * @returns {string} - User id.
3562
+ */
3563
+ frontendSyncBootstrapUserId() {
3564
+ const ability = this.currentAbility()
3565
+ const currentUser = ability?.currentUser()
3566
+
3567
+ if (typeof currentUser === "string" || typeof currentUser === "number") return String(currentUser)
3568
+ if (currentUser && typeof currentUser === "object") {
3569
+ const userRecord = /** @type {{id?: string | number | (() => string | number)}} */ (currentUser)
3570
+ const idValue = typeof userRecord.id === "function" ? userRecord.id() : userRecord.id
3571
+
3572
+ if (typeof idValue === "string" || typeof idValue === "number") return String(idValue)
3573
+ }
3574
+
3575
+ throw new Error("Expected sync bootstrap current user")
3576
+ }
3577
+
3578
+ /**
3579
+ * Runs frontend sync replay.
3580
+ * @returns {Promise<void>} - Sync replay response with per-mutation results.
3581
+ */
3582
+ async frontendSyncReplay() {
3583
+ if (this.request().httpMethod() === "OPTIONS") {
3584
+ await this.render({status: 204, json: {}})
3585
+ return
3586
+ }
3587
+
3588
+ const params = /** @type {Record<string, ?>} */ (deserializeFrontendModelTransportValue(this.params()))
3589
+ const signedMutations = this.frontendSyncReplaySignedMutations(params)
3590
+ const results = []
3591
+
3592
+ for (const signedMutation of signedMutations) {
3593
+ let idempotencyKey = null
3594
+
3595
+ try {
3596
+ idempotencyKey = mutationIdempotencyKey(/** @type {import("./sync/device-identity.js").SignedSyncMutation} */ (signedMutation))
3597
+ const response = await this.frontendSyncReplaySignedMutation(signedMutation)
3598
+
3599
+ results.push({idempotencyKey, response, status: "success"})
3600
+ } catch (error) {
3601
+ const errorContext = this.frontendModelEndpointErrorContext({
3602
+ action: "frontendSyncReplay",
3603
+ commandType: signedMutation && typeof signedMutation === "object" && "mutation" in signedMutation
3604
+ ? /** @type {{mutation?: {operation?: ?}}} */ (signedMutation).mutation?.operation
3605
+ : undefined,
3606
+ error,
3607
+ model: signedMutation && typeof signedMutation === "object" && "mutation" in signedMutation
3608
+ ? /** @type {{mutation?: {model?: ?}}} */ (signedMutation).mutation?.model
3609
+ : undefined
3610
+ })
3611
+
3612
+ await this.frontendModelLogEndpointError({
3613
+ action: errorContext.action,
3614
+ commandType: errorContext.commandType,
3615
+ error,
3616
+ model: errorContext.model
3617
+ })
3618
+
3619
+ results.push({
3620
+ idempotencyKey,
3621
+ response: await this.frontendModelClientErrorPayloadForError(error, errorContext),
3622
+ status: "error"
3623
+ })
3624
+ }
3625
+ }
3626
+
3627
+ await this.render({
3628
+ json: /** @type {Record<string, ?>} */ (serializeFrontendModelTransportValue({
3629
+ results,
3630
+ status: "success"
3631
+ }, this.transportSerializationOptions()))
3632
+ })
3633
+ }
3634
+
3635
+ /**
3636
+ * Resolves signed replay mutations from request params.
3637
+ * @param {Record<string, ?>} params - Request params.
3638
+ * @returns {Array<?>} - Signed mutation envelopes.
3639
+ */
3640
+ frontendSyncReplaySignedMutations(params) {
3641
+ if (Array.isArray(params.mutations)) return params.mutations
3642
+ if (params.mutation) return [params.mutation]
3643
+
3644
+ throw new Error("Expected sync replay mutation or mutations")
3645
+ }
3646
+
3647
+ /**
3648
+ * Verifies and replays one signed sync mutation.
3649
+ * @param {?} signedMutation - Signed mutation envelope.
3650
+ * @returns {Promise<Record<string, ?>>} - Frontend-model command response.
3651
+ */
3652
+ async frontendSyncReplaySignedMutation(signedMutation) {
3653
+ const configuration = this.getConfiguration()
3654
+ const syncConfiguration = configuration.getSyncConfiguration()
3655
+ const backendPublicKey = syncConfiguration.deviceCertificateBackendPublicKey
3656
+
3657
+ if (!backendPublicKey) throw frontendSyncReplaySafeError("sync.deviceCertificateBackendPublicKey is required for sync replay")
3658
+
3659
+ let mutation
3660
+
3661
+ try {
3662
+ mutation = await verifySignedMutation({
3663
+ backendPublicKey,
3664
+ signedMutation: /** @type {import("./sync/device-identity.js").SignedSyncMutation} */ (signedMutation)
3665
+ })
3666
+ } catch (error) {
3667
+ throw frontendSyncReplaySafeError(error instanceof Error ? error.message : String(error), error)
3668
+ }
3669
+
3670
+ const syncManifest = frontendModelSyncManifestForBackendProjects(configuration.getBackendProjects())
3671
+ const syncResource = syncManifest[mutation.model]
3672
+
3673
+ if (!syncResource) throw frontendSyncReplaySafeError(`Sync replay model is not enabled: ${mutation.model}`)
3674
+ if (!syncResource.operations.includes(mutation.operation)) {
3675
+ throw frontendSyncReplaySafeError(`Sync replay operation is not enabled for ${mutation.model}: ${mutation.operation}`)
3676
+ }
3677
+ if (syncResource.policyHash !== mutation.policyHash) {
3678
+ throw frontendSyncReplaySafeError(`Sync replay policy hash mismatch for ${mutation.model}`)
3679
+ }
3680
+ if (!["create", "update", "destroy"].includes(mutation.operation)) {
3681
+ throw frontendSyncReplaySafeError(`Sync replay operation is not supported yet: ${mutation.operation}`)
3682
+ }
3683
+
3684
+ const signedOfflineGrant = this.frontendSyncReplaySignedOfflineGrant(signedMutation)
3685
+ const offlineGrant = await this.frontendSyncReplayVerifiedOfflineGrant({
3686
+ signedOfflineGrant,
3687
+ signingKeys: syncConfiguration.offlineGrantSigningKeys
3688
+ })
3689
+
3690
+ this.frontendSyncReplayValidateOfflineGrant({mutation, offlineGrant, syncResource})
3691
+
3692
+ const commandParams = await this.frontendSyncReplayCommandParams(mutation)
3693
+
3694
+ try {
3695
+ return await this.withFrontendModelParams(commandParams, async () => {
3696
+ return await this.withFrontendModelRequestContext(commandParams, this.response(), async () => {
3697
+ return await this.frontendModelCommandPayload(/** @type {"create" | "update" | "destroy"} */ (mutation.operation)) || this.frontendModelErrorPayload("Action halted by beforeAction.")
3698
+ })
3699
+ })
3700
+ } catch (error) {
3701
+ const errorContext = this.frontendModelEndpointErrorContext({
3702
+ action: "frontendSyncReplay",
3703
+ commandType: /** @type {"create" | "update" | "destroy"} */ (mutation.operation),
3704
+ error,
3705
+ model: mutation.model
3706
+ })
3707
+
3708
+ await this.frontendModelLogEndpointError({
3709
+ action: errorContext.action,
3710
+ commandType: errorContext.commandType,
3711
+ error,
3712
+ model: errorContext.model
3713
+ })
3714
+
3715
+ return await this.frontendModelClientErrorPayloadForError(error, errorContext)
3716
+ }
3717
+ }
3718
+
3719
+ /**
3720
+ * Resolves the signed offline grant carried by a replay request.
3721
+ * @param {?} signedMutation - Signed mutation envelope.
3722
+ * @returns {?} - Signed offline grant envelope.
3723
+ */
3724
+ frontendSyncReplaySignedOfflineGrant(signedMutation) {
3725
+ if (!signedMutation || typeof signedMutation !== "object" || Array.isArray(signedMutation)) {
3726
+ throw frontendSyncReplaySafeError("Expected sync replay signed offline grant")
3727
+ }
3728
+
3729
+ const signedMutationRecord = /** @type {Record<string, ?>} */ (signedMutation)
3730
+ const signedOfflineGrant = signedMutationRecord.signedOfflineGrant || signedMutationRecord.offlineGrant || signedMutationRecord.signedGrant
3731
+
3732
+ if (!signedOfflineGrant) throw frontendSyncReplaySafeError("Expected sync replay signed offline grant")
3733
+
3734
+ return signedOfflineGrant
3735
+ }
3736
+
3737
+ /**
3738
+ * Verifies a sync replay signed offline grant.
3739
+ * @param {object} args - Arguments.
3740
+ * @param {?} args.signedOfflineGrant - Signed offline grant envelope.
3741
+ * @param {import("./sync/offline-grant.js").OfflineGrantSigningKey[]} args.signingKeys - Available signing keys.
3742
+ * @returns {Promise<import("./sync/offline-grant.js").OfflineGrant>} - Verified offline grant.
3743
+ */
3744
+ async frontendSyncReplayVerifiedOfflineGrant({signedOfflineGrant, signingKeys}) {
3745
+ try {
3746
+ return await verifyOfflineGrant({
3747
+ now: new Date(),
3748
+ signedGrant: /** @type {import("./sync/offline-grant.js").SignedOfflineGrant} */ (signedOfflineGrant),
3749
+ signingKeys
3750
+ })
3751
+ } catch (error) {
3752
+ throw frontendSyncReplaySafeError(error instanceof Error ? error.message : String(error), error)
3753
+ }
3754
+ }
3755
+
3756
+ /**
3757
+ * Validates that a verified offline grant authorizes a replayed mutation.
3758
+ * @param {object} args - Arguments.
3759
+ * @param {import("./sync/device-identity.js").SyncMutation} args.mutation - Verified mutation.
3760
+ * @param {import("./sync/offline-grant.js").OfflineGrant} args.offlineGrant - Verified grant.
3761
+ * @param {Record<string, ?>} args.syncResource - Current sync resource entry.
3762
+ * @returns {void} - Throws when unauthorized.
3763
+ */
3764
+ frontendSyncReplayValidateOfflineGrant({mutation, offlineGrant, syncResource}) {
3765
+ if (offlineGrant.grantId !== mutation.offlineGrantId) {
3766
+ throw frontendSyncReplaySafeError("Sync replay offline grant does not match mutation")
3767
+ }
3768
+ if (offlineGrant.deviceId !== mutation.actorDeviceId) {
3769
+ throw frontendSyncReplaySafeError("Sync replay offline grant device does not match mutation")
3770
+ }
3771
+ if (offlineGrant.userId !== mutation.actorUserId) {
3772
+ throw frontendSyncReplaySafeError("Sync replay offline grant user does not match mutation")
3773
+ }
3774
+
3775
+ const grantResource = /** @type {Record<string, ?> | undefined} */ (offlineGrant.resources[mutation.model])
3776
+ const grantOperations = Array.isArray(grantResource?.operations) ? grantResource.operations : []
3777
+ const grantPolicyHash = grantResource?.policyHash
3778
+
3779
+ if (!grantResource || grantResource.enabled !== true) throw frontendSyncReplaySafeError(`Sync replay offline grant does not authorize ${mutation.model}`)
3780
+ if (!grantOperations.includes(mutation.operation)) {
3781
+ throw frontendSyncReplaySafeError(`Sync replay offline grant does not authorize ${mutation.model}: ${mutation.operation}`)
3782
+ }
3783
+ if (grantPolicyHash !== mutation.policyHash || grantPolicyHash !== syncResource.policyHash) {
3784
+ throw frontendSyncReplaySafeError(`Sync replay offline grant policy hash mismatch for ${mutation.model}`)
3785
+ }
3786
+ if (!offlineGrant.scopes || typeof offlineGrant.scopes !== "object" || Array.isArray(offlineGrant.scopes)) {
3787
+ throw frontendSyncReplaySafeError("Sync replay offline grant scopes are invalid")
3788
+ }
3789
+ }
3790
+
3791
+ /**
3792
+ * Builds frontend-model command params for a verified replay mutation.
3793
+ * @param {import("./sync/device-identity.js").SyncMutation} mutation - Verified mutation.
3794
+ * @returns {Promise<Record<string, ?>>} - Frontend-model command params.
3795
+ */
3796
+ async frontendSyncReplayCommandParams(mutation) {
3797
+ const payload = mutation.payload && typeof mutation.payload === "object" && !Array.isArray(mutation.payload) ? mutation.payload : {}
3798
+ const {attributes, primaryKeyValue} = await this.frontendSyncReplayCommandAttributes(mutation)
3799
+ const commandParams = /** @type {Record<string, ?>} */ ({
3800
+ ...payload,
3801
+ attributes,
3802
+ model: mutation.model
3803
+ })
3804
+
3805
+ if (mutation.operation !== "create") {
3806
+ const id = commandParams.id || commandParams.recordId || primaryKeyValue
3807
+
3808
+ if (typeof id !== "string" && typeof id !== "number") throw frontendSyncReplaySafeError(`Sync replay ${mutation.operation} requires an id`)
3809
+
3810
+ commandParams.id = id
3811
+ }
3812
+
3813
+ return commandParams
3814
+ }
3815
+
3816
+ /**
3817
+ * Resolves command attributes and primary key from a replay mutation.
3818
+ * @param {import("./sync/device-identity.js").SyncMutation} mutation - Verified mutation.
3819
+ * @returns {Promise<{attributes: Record<string, ?>, primaryKeyValue: string | number | undefined}>} - Command attributes and primary key value.
3820
+ */
3821
+ async frontendSyncReplayCommandAttributes(mutation) {
3822
+ const attributes = /** @type {Record<string, ?>} */ ({...(mutation.attributes || {})})
3823
+ const frontendModelResource = this.getConfiguration().getBackendProjects()
3824
+ .map((backendProject) => this.frontendModelResourceConfigurationForBackendProjectModelName({backendProject, modelName: mutation.model}))
3825
+ .find((resourceConfiguration) => resourceConfiguration)
3826
+
3827
+ if (!frontendModelResource) return {attributes, primaryKeyValue: undefined}
3828
+
3829
+ const primaryKey = typeof frontendModelResource.resourceConfiguration.primaryKey === "string" ? frontendModelResource.resourceConfiguration.primaryKey : "id"
3830
+ const primaryKeyAttribute = attributes[primaryKey]
3831
+ const primaryKeyValue = typeof primaryKeyAttribute === "string" || typeof primaryKeyAttribute === "number" ? primaryKeyAttribute : undefined
3832
+
3833
+ if (primaryKeyValue !== undefined && mutation.operation !== "create") delete attributes[primaryKey]
3834
+
3835
+ return {attributes, primaryKeyValue}
3836
+ }
3837
+
3441
3838
  /**
3442
3839
  * Runs frontend api.
3443
3840
  * @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
  }