Source: client/actor-request-factory.js

/*
 * Copyright 2023, TeamDev. All rights reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Redistribution and use in source and/or binary forms, with or without
 * modification, must retain the above copyright notice and the following
 * disclaimer.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

"use strict";

import {v4 as newUuid} from 'uuid';

import {Message} from 'google-protobuf';
import {FieldMask} from '../proto/google/protobuf/field_mask_pb';
import {Timestamp} from '../proto/google/protobuf/timestamp_pb';
import {
  CompositeFilter,
  Filter,
  IdFilter,
  Target,
  TargetFilters
} from '../proto/spine/client/filters_pb';
import {OrderBy, Query, QueryId, ResponseFormat} from '../proto/spine/client/query_pb';
import {Topic, TopicId} from '../proto/spine/client/subscription_pb';
import {ActorContext} from '../proto/spine/core/actor_context_pb';
import {Command, CommandContext, CommandId} from '../proto/spine/core/command_pb';
import {UserId} from '../proto/spine/core/user_id_pb';
import {ZoneId, ZoneOffset} from '../proto/spine/time/time_pb';
import {isProtobufMessage, Type, TypedMessage} from './typed-message';
import {AnyPacker} from './any-packer';
import {FieldPaths} from './field-paths';
import {EnumValue} from 'google-protobuf/google/protobuf/type_pb';

/**
 * Wraps the passed enum value as Protobuf `EnumValue` so it can be correctly processed by the
 * server.
 *
 * As enums in Protobuf JS are declared as plain `number`s, their values can be passed to this
 * method as-is, for example: `enumValueOf(Task.Severity.HIGH)`.
 *
 * @param {!number} value the enum value
 * @returns {EnumValue} the `EnumValue` instance
 */
export function enumValueOf(value) {
  const result = new EnumValue();
  result.setNumber(value);
  return result;
}

const ENUM_VALUE_TYPE_URL = 'type.googleapis.com/google.protobuf.EnumValue';

// TODO:2019-06-07:yegor.udovchenko: Cover `Filters` class with the unit tests
// https://github.com/SpineEventEngine/web/issues/100
/**
 * A factory for `Filter` and `CompositeFilter` instances.
 */
export class Filters {

  /**
   * @typedef {string | number | boolean | Date | TypedMessage<T> | Message} FieldValue
   *
   * Represents all types acceptable as a value for filtering.
   *
   * @template <T> a type of the Protobuf message to compare with
   */

  /**
   * Instantiation not allowed and will throw an error.
   */
  constructor() {
    throw new Error('Tried instantiating a utility class.');
  }

  /**
   * Creates a new filter for the value of an object field to be equal to the provided value.
   *
   * @param {!String} fieldPath a path to the object field
   * @param {!FieldValue} value a value to compare with
   *
   * @return {Filter} a new filter instance
   */
  static eq(fieldPath, value) {
    return Filters.with(fieldPath, Filter.Operator.EQUAL, value);
  }

  /**
   * Creates a new filter for the value of an object field to be less than the provided value.
   *
   * @param {!String} fieldPath a path to the object field
   * @param {!FieldValue} value a value to compare with
   *
   * @return {Filter} a new filter instance
   */
  static lt(fieldPath, value) {
    return Filters.with(fieldPath, Filter.Operator.LESS_THAN, value);
  }

  /**
   * Creates a new filter for the value an object field to be greater than the provided value.
   *
   * @param {!String} fieldPath a path to the object field
   * @param {!FieldValue} value a value to compare with
   *
   * @return {Filter} a new filter instance
   */
  static gt(fieldPath, value) {
    return Filters.with(fieldPath, Filter.Operator.GREATER_THAN, value);
  }

  /**
   * Creates a new filter for the value of an object field to be less or equal compared to
   * the provided value.
   *
   * @param {!String} fieldPath a path to the object field
   * @param {!FieldValue} value a value to compare with
   *
   * @return {Filter} a new filter instance
   */
  static le(fieldPath, value) {
    return Filters.with(fieldPath, Filter.Operator.LESS_OR_EQUAL, value);
  }

  /**
   * Creates a new filter for the value of an object field to be greater or equal compared to
   * the provided value.
   *
   * @param {!String} fieldPath a path to the object field
   * @param {!FieldValue} value a value to compare with
   *
   * @return {Filter} a new filter instance
   */
  static ge(fieldPath, value) {
    return Filters.with(fieldPath, Filter.Operator.GREATER_OR_EQUAL, value);
  }

  /**
   * Creates a filter for an object field to match the provided value according to an operator.
   *
   * Accepts various types of {@link FieldValue field values}.
   *
   * @example
   * // Create filters with primitive values to compare
   * Filters.eq('description', 'Sample task description') // Wraps string in the Protobuf `StringValue`
   * Filters.gt('length', 12) // Wraps number in the Protobuf `Int32Value`
   * Filters.eq('multiline', false) // Wraps boolean in the Protobuf `BoolValue`
   *
   * @example
   * // Create filter for the primitive value of a custom type
   * Filters.gt('price', TypedMessage.float(7.41))
   *
   * @example
   * // Create filter for the time-based value
   * Filters.gt('whenCreated', new Date(2019, 5, 4)) // Converts the given date to the `Timestamp` message
   *
   * @example
   * // Create filter for the user-defined type
   * Filters.eq('status', Task.Status.COMPLETED)
   *
   * @param {!String} fieldPath a path to the object field
   * @param {!Filter.Operator} operator an operator to check the field value upon
   * @param {!FieldValue} value a value to compare the field value to
   *
   * @return {Filter} a new filter instance
   */
  static with(fieldPath, operator, value) {
    let typedValue;

    if (value instanceof Number || typeof value === 'number') {
      typedValue = TypedMessage.int32(value);
    } else if (value instanceof String || typeof value === 'string') {
      typedValue = TypedMessage.string(value);
    } else if (value instanceof Boolean || typeof value === 'boolean') {
      typedValue = TypedMessage.bool(value);
    } else if (value instanceof Date) {
      typedValue = TypedMessage.timestamp(value);
    } else if (value instanceof EnumValue) {
      const type = Type.of(EnumValue, ENUM_VALUE_TYPE_URL);
      typedValue = new TypedMessage(value, type);
    } else if (value instanceof TypedMessage) {
      typedValue = value;
    } else if(isProtobufMessage(value)) {
      typedValue = TypedMessage.of(value);
    } else {
      throw new Error(`Unable to create filter.
       Filter value type of ${typeof value} is unsupported.`)
    }

    const wrappedValue = AnyPacker.packTyped(typedValue);
    const filter = new Filter();
    filter.setFieldPath(FieldPaths.parse(fieldPath));
    filter.setValue(wrappedValue);
    filter.setOperator(operator);
    return filter;
  }

  /**
   * Creates a new composite filter which matches objects that fit every provided filter.
   *
   * @param {!Filter[]} filters an array of simple filters
   *
   * @return {CompositeFilter} a new composite filter with `ALL` operator
   */
  static all(filters) {
    return Filters.compose(filters, CompositeFilter.CompositeOperator.ALL);
  }

  /**
   * Creates a new composite filter which matches objects that fit at least one
   * of the provided filters.
   *
   * @param {!Filter[]} filters an array of simple filters
   *
   * @return {CompositeFilter} a new composite filter with `EITHER` operator
   */
  static either(filters) {
    return Filters.compose(filters, CompositeFilter.CompositeOperator.EITHER);
  }

  /**
   * Creates a new composite filter which matches objects according to an array of filters with a
   * specified logical operator.
   *
   * @param {!Filter[]} filters an array of simple filters
   * @param {!CompositeFilter.CompositeOperator} operator a logical operator for `filters`
   *
   * @return {CompositeFilter} a new composite filter
   */
  static compose(filters, operator) {
    const compositeFilter = new CompositeFilter();
    compositeFilter.setFilterList(filters);
    compositeFilter.setOperator(operator);
    return compositeFilter;
  }
}

/**
 * Utilities for working with `Query` and `Topic` targets.
 */
class Targets {

  /**
   * Instantiation not allowed and will throw an error.
   */
  constructor() {
    throw new Error('Tried instantiating a utility class.');
  }

  /**
   * Composes a new target for objects of specified type, optionally with specified IDs and
   * filters.
   *
   * @param {!Type} type a Type URL of target objects
   * @param {?TypedMessage[]} ids an array of IDs one of which must be matched by each target
   *                              object
   * @param {?CompositeFilter[]} filters an array of filters target
   *
   * @return {Target} a newly created target for objects matching the specified filters
   */
  static compose({forType: type, withIds: ids, filteredBy: filters}) {
    const includeAll = !ids && !filters;

    if (includeAll) {
      return Targets._all(type);
    }

    const targetFilters = new TargetFilters();

    const idList = Targets._nullToEmpty(ids);
    if (idList.length) {
      const idFilter = Targets._assembleIdFilter(idList);
      targetFilters.setIdFilter(idFilter);
    }

    const filterList = Targets._nullToEmpty(filters);
    if (filterList) {
      targetFilters.setFilterList(filterList);
    }

    return Targets._filtered(type, targetFilters);
  }

  /**
   * Creates a new target including all items of type.
   *
   * @param {!Type} type
   * @return {Target}
   * @private
   */
  static _all(type) {
    const target = new Target();
    target.setType(type.url().value());
    target.setIncludeAll(true);
    return target;
  }

  /**
   * Creates a new target including only items of the specified type that pass filtering.
   *
   * @param {!Type} type
   * @param {!TargetFilters} filters
   * @return {Target}
   * @private
   */
  static _filtered(type, filters) {
    const target = new Target();
    target.setType(type.url().value());
    target.setFilters(filters);
    return target;
  }

  /**
   * Creates a targets ID filter including only items which are included in the provided ID list.
   *
   * @param {!TypedMessage[]} ids an array of IDs for items matching target to be included in
   * @return {IdFilter}
   * @private
   */
  static _assembleIdFilter(ids) {
    const idFilter = new IdFilter();
    ids.forEach(rawId => {
      const packedId = AnyPacker.packTyped(rawId);
      idFilter.addId(packedId);
    });
    return idFilter;
  }

  /**
   * @param {?T[]} input
   * @return {T[]} an empty array if the value is `null`, or the provided input otherwise
   * @template <T> type of items in the provided array
   * @private
   */
  static _nullToEmpty(input) {
    if (input == null) {
      return [];
    } else {
      return input;
    }
  }
}

const INVALID_FILTER_TYPE =
  'All filters passed to QueryFilter#where() must be of a single type: ' +
  'either Filter or CompositeFilter.';

/**
 * An abstract base for builders that create `Message` instances which have a `Target`
 * and a `FieldMask` as attributes.
 *
 * <p>The `Target` matching the builder configuration is accessed with `#getTarget()`,
 * while the `FieldMask` is retrieved with `#getMask()`.
 *
 * The public API of this class is inspired by the SQL syntax.
 * ```javascript
 *     select(CUSTOMER_TYPE) // returning <AbstractTargetBuilder> descendant instance
 *         .byIds(getWestCoastCustomerIds())
 *         .withMask(["name", "address", "email"])
 *         .where([
 *             Filters.eq("type", "permanent"),
 *             Filters.eq("discountPercent", 10),
 *             Filters.eq("companySize", Company.Size.SMALL)
 *         ])
 *         .build()
 * ```
 *
 * @template <T>
 *         a type of the message which is returned by the implementations `#build()`
 * @abstract
 */
class AbstractTargetBuilder {

  /**
   * @param {!Class<Message>} entity a Protobuf type of the target entities
   */
  constructor(entity) {
    /**
     * A type composed from the target entity class.
     * 
     * @type {Type}
     * @private
     */
    this._type = Type.forClass(entity);
    /**
     * @type {TypedMessage[]}
     * @private
     */
    this._ids = null;
    /**
     * @type {CompositeFilter[]}
     * @private
     */
    this._filters = null;
    /**
     * @type {FieldMask}
     * @private
     */
    this._fieldMask = null;
  }

  /**
   * Sets an ID predicate of the `Query#getTarget()`.
   *
   * Makes the query return only the items identified by the provided IDs.
   *
   * Supported ID types are string, number, and Protobuf messages. All of the passed
   * IDs must be of the same type.
   *
   * If number IDs are passed they are assumed to be of `int64` Protobuf type.
   *
   * @param {!Message[]|!Number[]|!String[]} ids an array with identifiers to query
   * @return {this} the current builder instance
   * @throws if this method is executed more than once
   * @throws if the provided IDs are not an instance of `Array`
   * @throws if any of provided IDs are not an instance of supported types
   * @throws if the provided IDs are not of the same type
   */
  byIds(ids) {
    if (this._ids !== null) {
      throw new Error('Can not set query ID more than once for QueryBuilder.');
    }
    if (!(ids instanceof Array)) {
      throw new Error('Only an array of IDs is allowed as parameter to QueryBuilder#byIds().');
    }
    if (!ids.length) {
      return this;
    }
    const invalidTypeMessage = 'Each provided ID must be a string, number or a Protobuf message.';
    if (ids[0] instanceof Number || typeof ids[0] === 'number') {
      AbstractTargetBuilder._checkAllOfType(ids, Number, invalidTypeMessage);
      this._ids = ids.map(TypedMessage.int64);
    } else if (ids[0] instanceof String || typeof ids[0] === 'string') {
      AbstractTargetBuilder._checkAllOfType(ids, String, invalidTypeMessage);
      this._ids = ids.map(TypedMessage.string);
    } else if (!isProtobufMessage(ids[0])){
      throw new Error(invalidTypeMessage);
    } else {
      AbstractTargetBuilder._checkAllOfType(ids, ids[0].constructor, invalidTypeMessage);
      this._ids = ids.map(id => TypedMessage.of(id));
    }
    return this;
  }

  /**
   * Sets a field value predicate of the `Query#getTarget()`.
   *
   * <p>If there are no `Filter`s (i.e. the provided array is empty), all
   * the records will be returned by executing the `Query`.
   *
   * <p>An array of predicates provided to this method are considered to be joined in
   * a conjunction (using `CompositeFilter.CompositeOperator#ALL`). This means
   * a record would match this query only if it matches all of the predicates.
   *
   * @param {!Filter[]|CompositeFilter[]} predicates
   * the predicates to filter the requested items by
   * @return {this} self for method chaining
   * @throws if this method is executed more than once
   * @see Filters a convenient way to create `Filter` instances
   */
  where(predicates) {
    if (this._filters !== null) {
      throw new Error('Can not set filters more than once for QueryBuilder.');
    }
    if (!(predicates instanceof Array)) {
      throw new Error('Only an array of predicates is allowed as parameter to QueryBuilder#where().');
    }
    if (!predicates.length) {
      return this;
    }
    if (predicates[0] instanceof Filter) {
      AbstractTargetBuilder._checkAllOfType(predicates, Filter, INVALID_FILTER_TYPE);
      const aggregatingFilter = Filters.all(predicates);
      this._filters = [aggregatingFilter];
    } else {
      AbstractTargetBuilder._checkAllOfType(predicates, CompositeFilter, INVALID_FILTER_TYPE);
      this._filters = predicates.slice();
    }
    return this;
  }

  /**
   * Sets a Field Mask of the `Query`.
   *
   * The names of the fields must be formatted according to the `FieldMask`
   * specification.
   *
   * If there are no fields (i.e. an empty array is passed), all the fields will
   * be returned by query.
   *
   * @param {!String[]} fieldNames
   * @return {this} self for method chaining
   * @throws if this method is executed more than once
   * @see FieldMask specification for `FieldMask`
   */
  withMask(fieldNames) {
    if (this._fieldMask != null) {
      throw new Error('Can not set field mask more than once for QueryBuilder.');
    }
    if (!(fieldNames instanceof Array)) {
      throw new Error('Only an array of strings is allowed as parameter to QueryBuilder#withMask().');
    }
    AbstractTargetBuilder._checkAllOfType(fieldNames, String, 'Field names should be strings.');
    if (!fieldNames.length) {
      return this;
    }
    this._fieldMask = new FieldMask();
    this._fieldMask.setPathsList(fieldNames);
    return this;
  }

  /**
   * @return {Target} a target matching builders configuration
   */
  getTarget() {
    return this._buildTarget();
  }

  /**
   * Creates a new target `Target` instance based on this builder configuration.
   *
   * @return {Target} a new target
   */
  _buildTarget() {
    return Targets.compose({forType: this._type, withIds: this._ids, filteredBy: this._filters});
  }

  /**
   * @return {FieldMask} a fields mask set to this builder
   */
  getMask() {
    return this._fieldMask;
  }

  /**
   * A build method for creating instances of this builders target class.
   *
   * @return {T} a new target class instance
   * @abstract
   */
  build() {
    throw new Error('Not implemented in abstract base.');
  }

  /**
   * Checks that each provided item is an instance of the provided class. In case the check does
   * not pass an error is thrown.
   *
   * @param {!Array} items an array of objects that are expected to be of the provided type
   * @param {!Object} cls a class each item is required to be instance of
   * @param {!String} message an error message thrown on type mismatch
   * @private
   */
  static _checkAllOfType(items, cls, message = 'Unexpected parameter type.') {
    if (cls === String) {
      AbstractTargetBuilder._checkAllAreStrings(items, message);
    } else if (cls === Number) {
      AbstractTargetBuilder._checkAllAreNumbers(items, message);
    } else if (cls === Boolean) {
      AbstractTargetBuilder._checkAllAreBooleans(items, message);
    } else {
      AbstractTargetBuilder._checkAllOfClass(cls, items, message);
    }
  }

  /**
   * @param {!Array} items an array of objects that are expected to be strings
   * @param {!String} message an error message thrown on type mismatch
   * @private
   */
  static _checkAllAreStrings(items, message) {
    items.forEach(item => {
      if (typeof item !== 'string' && !(item instanceof String)) {
        throw new Error(message);
      }
    });
  }

  /**
   * @param {!Array} items an array of objects that are expected to be numbers
   * @param {!String} message an error message thrown on type mismatch
   * @private
   */
  static _checkAllAreNumbers(items, message) {
    items.forEach(item => {
      if (typeof item !== 'number' && !(item instanceof Number)) {
        throw new Error(message);
      }
    });
  }

  /**
   * @param {!Array} items an array of objects that are expected to be booleans
   * @param {!String} message an error message thrown on type mismatch
   * @private
   */
  static _checkAllAreBooleans(items, message) {
    items.forEach(item => {
      if (typeof item !== 'boolean' && !(item instanceof Boolean)) {
        throw new Error(message);
      }
    });
  }

  /**
   * @param {!Object} cls a class tyo check items against
   * @param {!Array} items an array of objects that are expected to instances of class
   * @param {!String} message an error message thrown on type mismatch
   * @private
   */
  static _checkAllOfClass(cls, items, message) {
    items.forEach(item => {
      if (!(item instanceof cls)) {
        throw new Error(message);
      }
    });
  }
}

/**
 * A builder for creating `Query` instances. A more flexible approach to query creation
 * than using a `QueryFactory`.
 *
 * @extends {AbstractTargetBuilder<Query>}
 * @template <T> a Protobuf type of the query target entities
 */
class QueryBuilder extends AbstractTargetBuilder {

  /**
   * @param {!Class<Message>} entity a Protobuf type of the query target entities
   * @param {!QueryFactory} queryFactory
   */
  constructor(entity, queryFactory) {
    super(entity);
    /**
     * @type {QueryFactory}
     * @private
     */
    this._factory = queryFactory;

    /**
     * @type {number}
     * @private
     */
    this._limit = 0;

    /**
     * @type {OrderBy}
     * @private
     */
    this._orderBy = null;
  }

  /**
   * Limits the query response to the given number of entities.
   *
   * The value must be non-negative, otherwise an error occurs. If set to `0`, all the available
   * entities are retrieved.
   *
   * When set, the result ordering must also be specified.
   *
   * @param {number} limit the max number of response entities
   */
  limit(limit) {
    if (limit < 0) {
      throw new Error("Query limit must not be negative.");
    }
    this._limit = limit;
    return this;
  }

  /**
   * Requests the query results to be ordered by the given `column` in the descending direction.
   *
   * Whether the results will be sorted in the requested order depends on the implementation of
   * server-side communication. For example, the Firebase-based communication protocol does not
   * preserve ordering. Regardless, if a `limit` is set for a query, an ordering is also required.
   *
   * @param column
   */
  orderDescendingBy(column) {
    this._addOrderBy(column, OrderBy.Direction.DESCENDING);
    return this;
  }

  /**
   * Requests the query results to be ordered by the given `column` in the ascending direction.
   *
   * Whether the results will be sorted in the requested order depends on the implementation of
   * server-side communication. For example, the Firebase-based communication protocol does not
   * preserve ordering. Regardless, if a `limit` is set for a query, an ordering is also required.
   *
   * @param column
   */
  orderAscendingBy(column) {
    this._addOrderBy(column, OrderBy.Direction.ASCENDING);
    return this;
  }

  /**
   * Specifies the expected response ordering.
   *
   * @param column the name of the column to order by
   * @param direction the direction of ordering: `OrderBy.Direction.ASCENDING` or
   *        `OrderBy.Direction.DESCENDING`
   * @private
   */
  _addOrderBy(column, direction) {
    if (column === null) {
      throw new Error("Column name must not be `null`.");
    }
    this._orderBy = new OrderBy();
    this._orderBy.setColumn(column);
    this._orderBy.setDirection(direction);
  }

  /**
   * Creates the Query instance based on the current builder configuration.
   *
   * @return {Query} a new query
   */
  build() {
    const target = this.getTarget();
    const fieldMask = this.getMask();
    const limit = this._limit;
    const order = this._orderBy;
    if (limit !== 0 && order === null) {
        throw Error("Ordering is required for queries with a `limit`.")
    }
    return this._factory.compose({forTarget: target,
                                  withMask: fieldMask,
                                  limit: limit,
                                  orderBy: order});
  }
}

/**
 * A factory for creating `Query` instances specifying the data to be retrieved from Spine server.
 *
 * @see ActorRequestFactory#query()
 * @template <T> a Protobuf type of the query target entities
 */
class QueryFactory {

  /**
   * @param {!ActorRequestFactory} requestFactory
   */
  constructor(requestFactory) {
    this._requestFactory = requestFactory;
  }

  /**
   * Creates a new builder of `Query` instances of the provided type.
   *
   * @param {!Class<Message>} entity a Protobuf type of the query target entities
   * @return {QueryBuilder}
   */
  select(entity) {
    return new QueryBuilder(entity, this);
  }

  /**
   * Creates a new `Query` which would return only entities which conform the target specification.
   *
   * An optional field mask can be provided to specify particular fields to be returned for `Query`
   *
   * @param {!Target} forTarget a specification of type and filters for `Query` result to match
   * @param {?FieldMask} fieldMask a specification of fields to be returned by executing `Query`
   * @param {number} limit max number of entities to fetch
   * @return {Query}
   */
  compose({forTarget: target, withMask: fieldMask, limit: limit, orderBy: orderBy}) {
    return this._newQuery(target, fieldMask, limit, orderBy);
  }

  /**
   * @param {!Target} target a specification of type and filters for `Query` result to match
   * @param {?FieldMask} fieldMask a specification of fields to be returned by executing `Query`
   * @param {?Number} limit the maximum number of the requested entities; must go with `orderBy`
   * @param {?OrderBy} orderBy ordering of the resulting entities
   * @return {Query} a new query instance
   * @private
   */
  _newQuery(target, fieldMask, limit, orderBy) {
    const id = QueryFactory._newId();
    const actorContext = this._requestFactory._actorContext();

    const format = new ResponseFormat();
    format.setFieldMask(fieldMask);
    format.setLimit(limit);
    format.setOrderBy(orderBy);

    const result = new Query();
    result.setId(id);
    result.setTarget(target);
    result.setFormat(format);
    result.setContext(actorContext);

    return result;
  }

  /**
   * @return {QueryId}
   * @private
   */
  static _newId() {
    const result = new QueryId();
    result.setValue(`q-${newUuid()}`);
    return result;
  }
}

/**
 * A factory of `Command` instances.
 *
 * Uses the given `ActorRequestFactory` as the source of the command meta information,
 * such as the actor, the tenant, etc.
 *
 * @see ActorRequestFactory#command()
 */
class CommandFactory {

  constructor(actorRequestFactory) {
    this._requestFactory = actorRequestFactory;
  }

  /**
   * Creates a `Command` from the given command message.
   *
   * @param {!Message} message a command message
   * @return {Command} a Spine Command
   */
  create(message) {
    const id = CommandFactory._newCommandId();
    const messageAny = AnyPacker.packMessage(message);
    const context = this._commandContext();

    const result = new Command();
    result.setId(id);
    result.setMessage(messageAny);
    result.setContext(context);
    return result;
  }

  _commandContext() {
    const result = new CommandContext();
    const actorContext = this._requestFactory._actorContext();
    result.setActorContext(actorContext);
    return result;
  }

  /**
   * @return {CommandId}
   * @private
   */
  static _newCommandId() {
    const result = new CommandId();
    result.setUuid(newUuid());
    return result;
  }
}

/**
 * A builder for creating `Topic` instances. A more flexible approach to query creation
 * than using a `TopicFactory`.
 *
 * @extends {AbstractTargetBuilder<Topic>}
 * @template <T> a Protobuf type of the subscription target entities
 */
class TopicBuilder extends AbstractTargetBuilder {

  /**
   * @param {!Class<Message>} entity a Protobuf type of the subscription target entities
   * @param {!TopicFactory} topicFactory
   */
  constructor(entity, topicFactory) {
    super(entity);
    /**
     * @type {TopicFactory}
     * @private
     */
    this._factory = topicFactory;
  }

  /**
   * Creates the `Topic` instance based on the current builder configuration.
   *
   * @return {Topic} a new topic
   */
  build() {
    return this._factory.compose({
      forTarget: this.getTarget(),
      withMask: this.getMask(),
    });
  }
}

/**
 * A factory of {@link Topic} instances.
 *
 * Uses the given {@link ActorRequestFactory} as the source of the topic meta information,
 * such as the actor.
 *
 * @see ActorRequestFactory#topic()
 * @template <T> a Protobuf type of the subscription target entities
 */
class TopicFactory {

  /**
   * @param {!ActorRequestFactory} actorRequestFactory
   * @constructor
   */
  constructor(actorRequestFactory) {
    this._requestFactory = actorRequestFactory;
  }

  /**
   * Creates a new builder of `Topic` instances of the provided type.
   *
   * @param {!Class<Message>} entity a Protobuf type of the subscription target entities
   * @return {TopicBuilder}
   */
  select(entity) {
    return new TopicBuilder(entity, this);
  }

  /**
   * Creates a `Topic` for the specified `Target`.
   *
   * @param {!Target} forTarget a `Target` to create a topic for
   * @param {?FieldMask} withMask a mask specifying fields to be returned
   * @return {Topic} the instance of `Topic`
   */
  compose({forTarget: target, withMask: fieldMask}) {
    const id = TopicFactory._generateId();
    const topic = new Topic();
    topic.setId(id);
    topic.setContext(this._requestFactory._actorContext());
    topic.setTarget(target);
    topic.setFieldMask(fieldMask);
    return topic;
  }

  /**
   * @return {TopicId} a newly created topic ID
   * @private
   */
  static _generateId() {
    const topicId = new TopicId();
    topicId.setValue(`t-${newUuid()}`);
    return topicId;
  }
}

/**
 * A provider of the actor that is used to associate requests to the backend
 * with an application user.
 */
export class ActorProvider {

  /**
   * @param {?UserId} actor an optional actor to be used for identifying requests to the backend;
   *                        if not specified, the anonymous actor is used
   */
  constructor(actor) {
    this.update(actor);
  }

  /**
   * Updates the actor ID value if it is different from the current, sets the
   * anonymous actor value if actor ID not specified or `null`.
   *
   * @param {?UserId} actorId
   */
  update(actorId) {
    if (typeof actorId === 'undefined' || actorId === null) {
      this._actor = ActorProvider.ANONYMOUS;
    } else {
      ActorProvider._ensureUserId(actorId);

      if (!Message.equals(this._actor, actorId)) {
        this._actor = actorId;
      }
    }
  }

  /**
   * @return {UserId} the current actor value
   */
  get() {
    return this._actor;
  }

  /**
   * Sets the anonymous actor value.
   */
  clear() {
    this._actor = ActorProvider.ANONYMOUS;
  }

  /**
   * Ensures if the object extends {@link UserId}.
   *
   * The implementation doesn't use `instanceof` check and check on prototypes
   * since they may fail if different versions of the file are used at the same time
   * (e.g. bundled and the original one).
   *
   * @param object the object to check
   */
  static _ensureUserId(object) {
    if (!(isProtobufMessage(object) && typeof object.getValue === 'function')) {
      throw new Error('The `spine.core.UserId` type was expected by `ActorProvider`.');
    }
  }
}

/**
 * The anonymous backend actor.
 *
 * It is needed for requests to the backend when the particular user is undefined.
 *
 * @type UserId
 */
ActorProvider.ANONYMOUS = function () {
  const actor = new UserId();
  actor.setValue('ANONYMOUS');
  return actor;
}();

/**
 * A factory for the various requests fired from the client-side by an actor.
 */
export class ActorRequestFactory {

  /**
   * Creates a new instance of ActorRequestFactory for the given actor.
   *
   * @param {!ActorProvider} actorProvider a provider of an actor
   * @param {?TenantProvider} tenantProvider a provider of the current tenant, if omitted, the
   *                                         application is considered single-tenant
   */
  constructor(actorProvider, tenantProvider) {
    this._actorProvider = actorProvider;
    this._tenantProvider = tenantProvider;
  }

  /**
   * Creates a new `ActorRequestFactory` based on the passed options.
   *
   * @param {!ClientOptions} options the client initialization options
   * @return {ActorRequestFactory} a new `ActorRequestFactory` instance
   */
  static create(options) {
    if (!options) {
      throw new Error('Client options are not defined.')
    }
    const actorProvider = options.actorProvider;
    if (!actorProvider) {
      throw new Error('The actor provider should be set in the client options in order to ' +
          'construct an `ActorRequestFactory`.');
    }
    return new ActorRequestFactory(actorProvider, options.tenantProvider);
  }

  /**
   * Creates a new query factory for building various queries based on configuration of this
   * `ActorRequestFactory` instance.
   *
   * @return {QueryFactory}
   */
  query() {
    return new QueryFactory(this);
  }

  /**
   * Creates a new command factory for building various commands based on configuration of this
   * `ActorRequestFactory` instance.
   *
   * @return {CommandFactory}
   */
  command() {
    return new CommandFactory(this);
  }

  /**
   * Creates a new topic factory for building subscription topics based on configuration of this
   * `ActorRequestFactory` instance.
   *
   * @return {TopicFactory}
   */
  topic() {
    return new TopicFactory(this);
  }

  _actorContext() {
    const result = new ActorContext();
    if (this._tenantProvider) {
      result.setTenantId(this._tenantProvider.tenantId());
    }
    result.setActor(this._actorProvider.get());
    const seconds = Math.round(new Date().getTime() / 1000);
    const time = new Timestamp();
    time.setSeconds(seconds);
    result.setTimestamp(time);
    result.setZoneOffset(ActorRequestFactory._zoneOffset());
    return result;
  }

  /**
   * @return {ZoneOffset}
   * @protected
   */
  static _zoneOffset() {
    const format = new Intl.DateTimeFormat();
    const timeOptions = format.resolvedOptions();
    const zoneId = new ZoneId();
    zoneId.setValue(timeOptions.timeZone);
    const zoneOffset = ActorRequestFactory._zoneOffsetSeconds();
    const result = new ZoneOffset();
    result.setAmountSeconds(zoneOffset);
    return result;
  }

  /**
   * @return {number}
   * @private
   */
  static _zoneOffsetSeconds() {
    return new Date().getTimezoneOffset() * 60;
  }
}