velocious 1.0.475 → 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 (86) hide show
  1. package/README.md +3 -0
  2. package/build/configuration-types.js +1 -0
  3. package/build/configuration.js +5 -0
  4. package/build/database/drivers/base.js +16 -0
  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 +13 -0
  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/sql/remove-index.js +6 -0
  13. package/build/database/migration/index.js +44 -0
  14. package/build/database/pool/base-methods-forward.js +1 -0
  15. package/build/database/query/remove-index-base.js +39 -0
  16. package/build/database/record/index.js +25 -9
  17. package/build/environment-handlers/node/cli/commands/generate/base-models.js +36 -0
  18. package/build/frontend-model-controller.js +275 -1
  19. package/build/routes/hooks/frontend-model-command-route-hook.js +8 -0
  20. package/build/src/configuration-types.d.ts +5 -0
  21. package/build/src/configuration-types.d.ts.map +1 -1
  22. package/build/src/configuration-types.js +2 -1
  23. package/build/src/configuration.d.ts.map +1 -1
  24. package/build/src/configuration.js +6 -1
  25. package/build/src/database/drivers/base.d.ts +20 -0
  26. package/build/src/database/drivers/base.d.ts.map +1 -1
  27. package/build/src/database/drivers/base.js +16 -1
  28. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  29. package/build/src/database/drivers/mssql/index.js +12 -1
  30. package/build/src/database/drivers/mssql/sql/remove-index.d.ts +4 -0
  31. package/build/src/database/drivers/mssql/sql/remove-index.d.ts.map +1 -0
  32. package/build/src/database/drivers/mssql/sql/remove-index.js +5 -0
  33. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  34. package/build/src/database/drivers/mysql/index.js +12 -1
  35. package/build/src/database/drivers/mysql/sql/remove-index.d.ts +4 -0
  36. package/build/src/database/drivers/mysql/sql/remove-index.d.ts.map +1 -0
  37. package/build/src/database/drivers/mysql/sql/remove-index.js +5 -0
  38. package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
  39. package/build/src/database/drivers/pgsql/index.js +12 -1
  40. package/build/src/database/drivers/pgsql/sql/remove-index.d.ts +4 -0
  41. package/build/src/database/drivers/pgsql/sql/remove-index.d.ts.map +1 -0
  42. package/build/src/database/drivers/pgsql/sql/remove-index.js +5 -0
  43. package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
  44. package/build/src/database/drivers/sqlite/base.js +12 -1
  45. package/build/src/database/drivers/sqlite/sql/remove-index.d.ts +4 -0
  46. package/build/src/database/drivers/sqlite/sql/remove-index.d.ts.map +1 -0
  47. package/build/src/database/drivers/sqlite/sql/remove-index.js +5 -0
  48. package/build/src/database/migration/index.d.ts +25 -0
  49. package/build/src/database/migration/index.d.ts.map +1 -1
  50. package/build/src/database/migration/index.js +39 -1
  51. package/build/src/database/pool/base-methods-forward.d.ts.map +1 -1
  52. package/build/src/database/pool/base-methods-forward.js +2 -1
  53. package/build/src/database/query/remove-index-base.d.ts +35 -0
  54. package/build/src/database/query/remove-index-base.d.ts.map +1 -0
  55. package/build/src/database/query/remove-index-base.js +34 -0
  56. package/build/src/database/record/index.d.ts +11 -0
  57. package/build/src/database/record/index.d.ts.map +1 -1
  58. package/build/src/database/record/index.js +26 -11
  59. package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts +15 -0
  60. package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts.map +1 -1
  61. package/build/src/environment-handlers/node/cli/commands/generate/base-models.js +33 -1
  62. package/build/src/frontend-model-controller.d.ts +62 -0
  63. package/build/src/frontend-model-controller.d.ts.map +1 -1
  64. package/build/src/frontend-model-controller.js +248 -2
  65. package/build/src/routes/hooks/frontend-model-command-route-hook.d.ts.map +1 -1
  66. package/build/src/routes/hooks/frontend-model-command-route-hook.js +8 -1
  67. package/build/tsconfig.tsbuildinfo +1 -1
  68. package/package.json +1 -1
  69. package/src/configuration-types.js +1 -0
  70. package/src/configuration.js +5 -0
  71. package/src/database/drivers/base.js +16 -0
  72. package/src/database/drivers/mssql/index.js +13 -0
  73. package/src/database/drivers/mssql/sql/remove-index.js +6 -0
  74. package/src/database/drivers/mysql/index.js +13 -0
  75. package/src/database/drivers/mysql/sql/remove-index.js +6 -0
  76. package/src/database/drivers/pgsql/index.js +13 -0
  77. package/src/database/drivers/pgsql/sql/remove-index.js +6 -0
  78. package/src/database/drivers/sqlite/base.js +13 -0
  79. package/src/database/drivers/sqlite/sql/remove-index.js +6 -0
  80. package/src/database/migration/index.js +44 -0
  81. package/src/database/pool/base-methods-forward.js +1 -0
  82. package/src/database/query/remove-index-base.js +39 -0
  83. package/src/database/record/index.js +25 -9
  84. package/src/environment-handlers/node/cli/commands/generate/base-models.js +36 -0
  85. package/src/frontend-model-controller.js +275 -1
  86. package/src/routes/hooks/frontend-model-command-route-hook.js +8 -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.
@@ -6,7 +6,8 @@ import FrontendModelBaseResource from "./frontend-model-resource/base-resource.j
6
6
  import Response from "./http-server/client/response.js"
7
7
  import {frontendModelResourcesWithBuiltInsForBackendProject} from "./frontend-models/built-in-resources.js"
8
8
  import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition, frontendModelResourcePath, frontendModelResourcesForBackendProject, frontendModelSyncManifestForBackendProjects} from "./frontend-models/resource-definition.js"
9
- import {createOfflineGrantFromBootstrap} from "./sync/offline-grant.js"
9
+ import {createOfflineGrantFromBootstrap, verifyOfflineGrant} from "./sync/offline-grant.js"
10
+ import {mutationIdempotencyKey, verifySignedMutation} from "./sync/device-identity.js"
10
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"
11
12
  import {assignSafeProperty, deserializeFrontendModelTransportValue, isBackendModelInstance, serializeFrontendModelTransportValue} from "./frontend-models/transport-serialization.js"
12
13
  import {requestDetails} from "./error-reporting/request-details.js"
@@ -172,6 +173,19 @@ const frontendModelWhereNoMatchSymbol = Symbol("frontendModelWhereNoMatch")
172
173
  const frontendModelClientSafeErrorMessage = "Request failed."
173
174
  const frontendModelDebugErrorEnvironments = new Set(["development", "test"])
174
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
+
175
189
  /**
176
190
  * Runs frontend model query metadata.
177
191
  * @param {import("./database/query/model-class-query.js").default} query - Query instance.
@@ -3561,6 +3575,266 @@ export default class FrontendModelController extends Controller {
3561
3575
  throw new Error("Expected sync bootstrap current user")
3562
3576
  }
3563
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
+
3564
3838
  /**
3565
3839
  * Runs frontend api.
3566
3840
  * @returns {Promise<void>} - Shared frontend model API action with batch support.
@@ -25,6 +25,14 @@ export default async function frontendModelCommandRouteHook({configuration, curr
25
25
  }
26
26
  }
27
27
 
28
+ if (normalizedCurrentPath === "/frontend-models/sync/replay") {
29
+ return {
30
+ action: "frontend-sync-replay",
31
+ controller: "velocious/api",
32
+ controllerPath: FRONTEND_MODEL_CONTROLLER_PATH
33
+ }
34
+ }
35
+
28
36
  if (normalizedCurrentPath === SHARED_FRONTEND_MODEL_API_PATH) {
29
37
  return {
30
38
  action: "frontend-api",