summaryrefslogtreecommitdiffstats
path: root/services/common/kinto-http-client.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--services/common/kinto-http-client.sys.mjs3061
1 files changed, 3061 insertions, 0 deletions
diff --git a/services/common/kinto-http-client.sys.mjs b/services/common/kinto-http-client.sys.mjs
new file mode 100644
index 0000000000..6ccaa520c1
--- /dev/null
+++ b/services/common/kinto-http-client.sys.mjs
@@ -0,0 +1,3061 @@
+/*
+ *
+ * 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
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * This file is generated from kinto.js - do not modify directly.
+ */
+
+import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs";
+
+/*
+ * Version 15.0.0 - c8775d9
+ */
+
+import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+
+/******************************************************************************
+Copyright (c) Microsoft Corporation.
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
+***************************************************************************** */
+/* global Reflect, Promise */
+
+function __decorate(decorators, target, key, desc) {
+ var c = arguments.length,
+ r =
+ c < 3
+ ? target
+ : desc === null
+ ? (desc = Object.getOwnPropertyDescriptor(target, key))
+ : desc,
+ d;
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function")
+ r = Reflect.decorate(decorators, target, key, desc);
+ else
+ for (var i = decorators.length - 1; i >= 0; i--)
+ if ((d = decorators[i]))
+ r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
+}
+
+/**
+ * Chunks an array into n pieces.
+ *
+ * @private
+ * @param {Array} array
+ * @param {Number} n
+ * @return {Array}
+ */
+function partition(array, n) {
+ if (n <= 0) {
+ return [array];
+ }
+ return array.reduce((acc, x, i) => {
+ if (i === 0 || i % n === 0) {
+ acc.push([x]);
+ } else {
+ acc[acc.length - 1].push(x);
+ }
+ return acc;
+ }, []);
+}
+/**
+ * Returns a Promise always resolving after the specified amount in milliseconds.
+ *
+ * @return Promise<void>
+ */
+function delay(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+/**
+ * Always returns a resource data object from the provided argument.
+ *
+ * @private
+ * @param {Object|String} resource
+ * @return {Object}
+ */
+function toDataBody(resource) {
+ if (isObject(resource)) {
+ return resource;
+ }
+ if (typeof resource === "string") {
+ return { id: resource };
+ }
+ throw new Error("Invalid argument.");
+}
+/**
+ * Transforms an object into an URL query string, stripping out any undefined
+ * values.
+ *
+ * @param {Object} obj
+ * @return {String}
+ */
+function qsify(obj) {
+ const encode = v =>
+ encodeURIComponent(typeof v === "boolean" ? String(v) : v);
+ const stripped = cleanUndefinedProperties(obj);
+ return Object.keys(stripped)
+ .map(k => {
+ const ks = encode(k) + "=";
+ if (Array.isArray(stripped[k])) {
+ return ks + stripped[k].map(v => encode(v)).join(",");
+ }
+ return ks + encode(stripped[k]);
+ })
+ .join("&");
+}
+/**
+ * Checks if a version is within the provided range.
+ *
+ * @param {String} version The version to check.
+ * @param {String} minVersion The minimum supported version (inclusive).
+ * @param {String} maxVersion The minimum supported version (exclusive).
+ * @throws {Error} If the version is outside of the provided range.
+ */
+function checkVersion(version, minVersion, maxVersion) {
+ const extract = str => str.split(".").map(x => parseInt(x, 10));
+ const [verMajor, verMinor] = extract(version);
+ const [minMajor, minMinor] = extract(minVersion);
+ const [maxMajor, maxMinor] = extract(maxVersion);
+ const checks = [
+ verMajor < minMajor,
+ verMajor === minMajor && verMinor < minMinor,
+ verMajor > maxMajor,
+ verMajor === maxMajor && verMinor >= maxMinor,
+ ];
+ if (checks.some(x => x)) {
+ throw new Error(
+ `Version ${version} doesn't satisfy ${minVersion} <= x < ${maxVersion}`
+ );
+ }
+}
+/**
+ * Generates a decorator function ensuring a version check is performed against
+ * the provided requirements before executing it.
+ *
+ * @param {String} min The required min version (inclusive).
+ * @param {String} max The required max version (inclusive).
+ * @return {Function}
+ */
+function support(min, max) {
+ return function (
+ // @ts-ignore
+ target,
+ key,
+ descriptor
+ ) {
+ const fn = descriptor.value;
+ return {
+ configurable: true,
+ get() {
+ const wrappedMethod = (...args) => {
+ // "this" is the current instance which its method is decorated.
+ const client = this.client ? this.client : this;
+ return client
+ .fetchHTTPApiVersion()
+ .then(version => checkVersion(version, min, max))
+ .then(() => fn.apply(this, args));
+ };
+ Object.defineProperty(this, key, {
+ value: wrappedMethod,
+ configurable: true,
+ writable: true,
+ });
+ return wrappedMethod;
+ },
+ };
+ };
+}
+/**
+ * Generates a decorator function ensuring that the specified capabilities are
+ * available on the server before executing it.
+ *
+ * @param {Array<String>} capabilities The required capabilities.
+ * @return {Function}
+ */
+function capable(capabilities) {
+ return function (
+ // @ts-ignore
+ target,
+ key,
+ descriptor
+ ) {
+ const fn = descriptor.value;
+ return {
+ configurable: true,
+ get() {
+ const wrappedMethod = (...args) => {
+ // "this" is the current instance which its method is decorated.
+ const client = this.client ? this.client : this;
+ return client
+ .fetchServerCapabilities()
+ .then(available => {
+ const missing = capabilities.filter(c => !(c in available));
+ if (missing.length) {
+ const missingStr = missing.join(", ");
+ throw new Error(
+ `Required capabilities ${missingStr} not present on server`
+ );
+ }
+ })
+ .then(() => fn.apply(this, args));
+ };
+ Object.defineProperty(this, key, {
+ value: wrappedMethod,
+ configurable: true,
+ writable: true,
+ });
+ return wrappedMethod;
+ },
+ };
+ };
+}
+/**
+ * Generates a decorator function ensuring an operation is not performed from
+ * within a batch request.
+ *
+ * @param {String} message The error message to throw.
+ * @return {Function}
+ */
+function nobatch(message) {
+ return function (
+ // @ts-ignore
+ target,
+ key,
+ descriptor
+ ) {
+ const fn = descriptor.value;
+ return {
+ configurable: true,
+ get() {
+ const wrappedMethod = (...args) => {
+ // "this" is the current instance which its method is decorated.
+ if (this._isBatch) {
+ throw new Error(message);
+ }
+ return fn.apply(this, args);
+ };
+ Object.defineProperty(this, key, {
+ value: wrappedMethod,
+ configurable: true,
+ writable: true,
+ });
+ return wrappedMethod;
+ },
+ };
+ };
+}
+/**
+ * Returns true if the specified value is an object (i.e. not an array nor null).
+ * @param {Object} thing The value to inspect.
+ * @return {bool}
+ */
+function isObject(thing) {
+ return typeof thing === "object" && thing !== null && !Array.isArray(thing);
+}
+/**
+ * Parses a data url.
+ * @param {String} dataURL The data url.
+ * @return {Object}
+ */
+function parseDataURL(dataURL) {
+ const regex = /^data:(.*);base64,(.*)/;
+ const match = dataURL.match(regex);
+ if (!match) {
+ throw new Error(`Invalid data-url: ${String(dataURL).substring(0, 32)}...`);
+ }
+ const props = match[1];
+ const base64 = match[2];
+ const [type, ...rawParams] = props.split(";");
+ const params = rawParams.reduce((acc, param) => {
+ const [key, value] = param.split("=");
+ return { ...acc, [key]: value };
+ }, {});
+ return { ...params, type, base64 };
+}
+/**
+ * Extracts file information from a data url.
+ * @param {String} dataURL The data url.
+ * @return {Object}
+ */
+function extractFileInfo(dataURL) {
+ const { name, type, base64 } = parseDataURL(dataURL);
+ const binary = atob(base64);
+ const array = [];
+ for (let i = 0; i < binary.length; i++) {
+ array.push(binary.charCodeAt(i));
+ }
+ const blob = new Blob([new Uint8Array(array)], { type });
+ return { blob, name };
+}
+/**
+ * Creates a FormData instance from a data url and an existing JSON response
+ * body.
+ * @param {String} dataURL The data url.
+ * @param {Object} body The response body.
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.filename] Force attachment file name.
+ * @return {FormData}
+ */
+function createFormData(dataURL, body, options = {}) {
+ const { filename = "untitled" } = options;
+ const { blob, name } = extractFileInfo(dataURL);
+ const formData = new FormData();
+ formData.append("attachment", blob, name || filename);
+ for (const property in body) {
+ if (typeof body[property] !== "undefined") {
+ formData.append(property, JSON.stringify(body[property]));
+ }
+ }
+ return formData;
+}
+/**
+ * Clones an object with all its undefined keys removed.
+ * @private
+ */
+function cleanUndefinedProperties(obj) {
+ const result = {};
+ for (const key in obj) {
+ if (typeof obj[key] !== "undefined") {
+ result[key] = obj[key];
+ }
+ }
+ return result;
+}
+/**
+ * Handle common query parameters for Kinto requests.
+ *
+ * @param {String} [path] The endpoint base path.
+ * @param {Array} [options.fields] Fields to limit the
+ * request to.
+ * @param {Object} [options.query={}] Additional query arguments.
+ */
+function addEndpointOptions(path, options = {}) {
+ const query = { ...options.query };
+ if (options.fields) {
+ query._fields = options.fields;
+ }
+ const queryString = qsify(query);
+ if (queryString) {
+ return path + "?" + queryString;
+ }
+ return path;
+}
+/**
+ * Replace authorization header with an obscured version
+ */
+function obscureAuthorizationHeader(headers) {
+ const h = new Headers(headers);
+ if (h.has("authorization")) {
+ h.set("authorization", "**** (suppressed)");
+ }
+ const obscuredHeaders = {};
+ for (const [header, value] of h.entries()) {
+ obscuredHeaders[header] = value;
+ }
+ return obscuredHeaders;
+}
+
+/**
+ * Kinto server error code descriptors.
+ */
+const ERROR_CODES = {
+ 104: "Missing Authorization Token",
+ 105: "Invalid Authorization Token",
+ 106: "Request body was not valid JSON",
+ 107: "Invalid request parameter",
+ 108: "Missing request parameter",
+ 109: "Invalid posted data",
+ 110: "Invalid Token / id",
+ 111: "Missing Token / id",
+ 112: "Content-Length header was not provided",
+ 113: "Request body too large",
+ 114: "Resource was created, updated or deleted meanwhile",
+ 115: "Method not allowed on this end point (hint: server may be readonly)",
+ 116: "Requested version not available on this server",
+ 117: "Client has sent too many requests",
+ 121: "Resource access is forbidden for this user",
+ 122: "Another resource violates constraint",
+ 201: "Service Temporary unavailable due to high load",
+ 202: "Service deprecated",
+ 999: "Internal Server Error",
+};
+class NetworkTimeoutError extends Error {
+ constructor(url, options) {
+ super(
+ `Timeout while trying to access ${url} with ${JSON.stringify(options)}`
+ );
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, NetworkTimeoutError);
+ }
+ this.url = url;
+ this.options = options;
+ }
+}
+class UnparseableResponseError extends Error {
+ constructor(response, body, error) {
+ const { status } = response;
+ super(
+ `Response from server unparseable (HTTP ${
+ status || 0
+ }; ${error}): ${body}`
+ );
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, UnparseableResponseError);
+ }
+ this.status = status;
+ this.response = response;
+ this.stack = error.stack;
+ this.error = error;
+ }
+}
+/**
+ * "Error" subclass representing a >=400 response from the server.
+ *
+ * Whether or not this is an error depends on your application.
+ *
+ * The `json` field can be undefined if the server responded with an
+ * empty response body. This shouldn't generally happen. Most "bad"
+ * responses come with a JSON error description, or (if they're
+ * fronted by a CDN or nginx or something) occasionally non-JSON
+ * responses (which become UnparseableResponseErrors, above).
+ */
+class ServerResponse extends Error {
+ constructor(response, json) {
+ const { status } = response;
+ let { statusText } = response;
+ let errnoMsg;
+ if (json) {
+ // Try to fill in information from the JSON error.
+ statusText = json.error || statusText;
+ // Take errnoMsg from either ERROR_CODES or json.message.
+ if (json.errno && json.errno in ERROR_CODES) {
+ errnoMsg = ERROR_CODES[json.errno];
+ } else if (json.message) {
+ errnoMsg = json.message;
+ }
+ // If we had both ERROR_CODES and json.message, and they differ,
+ // combine them.
+ if (errnoMsg && json.message && json.message !== errnoMsg) {
+ errnoMsg += ` (${json.message})`;
+ }
+ }
+ let message = `HTTP ${status} ${statusText}`;
+ if (errnoMsg) {
+ message += `: ${errnoMsg}`;
+ }
+ super(message.trim());
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, ServerResponse);
+ }
+ this.response = response;
+ this.data = json;
+ }
+}
+
+var errors = /*#__PURE__*/ Object.freeze({
+ __proto__: null,
+ NetworkTimeoutError,
+ ServerResponse,
+ UnparseableResponseError,
+ default: ERROR_CODES,
+});
+
+/**
+ * Enhanced HTTP client for the Kinto protocol.
+ * @private
+ */
+class HTTP {
+ /**
+ * Default HTTP request headers applied to each outgoing request.
+ *
+ * @type {Object}
+ */
+ static get DEFAULT_REQUEST_HEADERS() {
+ return {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ };
+ }
+ /**
+ * Default options.
+ *
+ * @type {Object}
+ */
+ static get defaultOptions() {
+ return { timeout: null, requestMode: "cors" };
+ }
+ /**
+ * Constructor.
+ *
+ * @param {EventEmitter} events The event handler.
+ * @param {Object} [options={}} The options object.
+ * @param {Number} [options.timeout=null] The request timeout in ms, if any (default: `null`).
+ * @param {String} [options.requestMode="cors"] The HTTP request mode (default: `"cors"`).
+ */
+ constructor(events, options = {}) {
+ // public properties
+ /**
+ * The event emitter instance.
+ * @type {EventEmitter}
+ */
+ this.events = events;
+ /**
+ * The request mode.
+ * @see https://fetch.spec.whatwg.org/#requestmode
+ * @type {String}
+ */
+ this.requestMode = options.requestMode || HTTP.defaultOptions.requestMode;
+ /**
+ * The request timeout.
+ * @type {Number}
+ */
+ this.timeout = options.timeout || HTTP.defaultOptions.timeout;
+ /**
+ * The fetch() function.
+ * @type {Function}
+ */
+ this.fetchFunc = options.fetchFunc || globalThis.fetch.bind(globalThis);
+ }
+ /**
+ * @private
+ */
+ timedFetch(url, options) {
+ let hasTimedout = false;
+ return new Promise((resolve, reject) => {
+ // Detect if a request has timed out.
+ let _timeoutId;
+ if (this.timeout) {
+ _timeoutId = setTimeout(() => {
+ hasTimedout = true;
+ if (options && options.headers) {
+ options = {
+ ...options,
+ headers: obscureAuthorizationHeader(options.headers),
+ };
+ }
+ reject(new NetworkTimeoutError(url, options));
+ }, this.timeout);
+ }
+ function proceedWithHandler(fn) {
+ return arg => {
+ if (!hasTimedout) {
+ if (_timeoutId) {
+ clearTimeout(_timeoutId);
+ }
+ fn(arg);
+ }
+ };
+ }
+ this.fetchFunc(url, options)
+ .then(proceedWithHandler(resolve))
+ .catch(proceedWithHandler(reject));
+ });
+ }
+ /**
+ * @private
+ */
+ async processResponse(response) {
+ const { status, headers } = response;
+ const text = await response.text();
+ // Check if we have a body; if so parse it as JSON.
+ let json;
+ if (text.length !== 0) {
+ try {
+ json = JSON.parse(text);
+ } catch (err) {
+ throw new UnparseableResponseError(response, text, err);
+ }
+ }
+ if (status >= 400) {
+ throw new ServerResponse(response, json);
+ }
+ return { status, json: json, headers };
+ }
+ /**
+ * @private
+ */
+ async retry(url, retryAfter, request, options) {
+ await delay(retryAfter);
+ return this.request(url, request, {
+ ...options,
+ retry: options.retry - 1,
+ });
+ }
+ /**
+ * Performs an HTTP request to the Kinto server.
+ *
+ * Resolves with an objet containing the following HTTP response properties:
+ * - `{Number} status` The HTTP status code.
+ * - `{Object} json` The JSON response body.
+ * - `{Headers} headers` The response headers object; see the ES6 fetch() spec.
+ *
+ * @param {String} url The URL.
+ * @param {Object} [request={}] The request object, passed to
+ * fetch() as its options object.
+ * @param {Object} [request.headers] The request headers object (default: {})
+ * @param {Object} [options={}] Options for making the
+ * request
+ * @param {Number} [options.retry] Number of retries (default: 0)
+ * @return {Promise}
+ */
+ async request(url, request = { headers: {} }, options = { retry: 0 }) {
+ // Ensure default request headers are always set
+ request.headers = { ...HTTP.DEFAULT_REQUEST_HEADERS, ...request.headers };
+ // If a multipart body is provided, remove any custom Content-Type header as
+ // the fetch() implementation will add the correct one for us.
+ if (request.body && request.body instanceof FormData) {
+ if (request.headers instanceof Headers) {
+ request.headers.delete("Content-Type");
+ } else if (!Array.isArray(request.headers)) {
+ delete request.headers["Content-Type"];
+ }
+ }
+ request.mode = this.requestMode;
+ const response = await this.timedFetch(url, request);
+ const { headers } = response;
+ this._checkForDeprecationHeader(headers);
+ this._checkForBackoffHeader(headers);
+ // Check if the server summons the client to retry after a while.
+ const retryAfter = this._checkForRetryAfterHeader(headers);
+ // If number of allowed of retries is not exhausted, retry the same request.
+ if (retryAfter && options.retry > 0) {
+ return this.retry(url, retryAfter, request, options);
+ }
+ return this.processResponse(response);
+ }
+ _checkForDeprecationHeader(headers) {
+ const alertHeader = headers.get("Alert");
+ if (!alertHeader) {
+ return;
+ }
+ let alert;
+ try {
+ alert = JSON.parse(alertHeader);
+ } catch (err) {
+ console.warn("Unable to parse Alert header message", alertHeader);
+ return;
+ }
+ console.warn(alert.message, alert.url);
+ if (this.events) {
+ this.events.emit("deprecated", alert);
+ }
+ }
+ _checkForBackoffHeader(headers) {
+ let backoffMs;
+ const backoffHeader = headers.get("Backoff");
+ const backoffSeconds = backoffHeader ? parseInt(backoffHeader, 10) : 0;
+ if (backoffSeconds > 0) {
+ backoffMs = new Date().getTime() + backoffSeconds * 1000;
+ } else {
+ backoffMs = 0;
+ }
+ if (this.events) {
+ this.events.emit("backoff", backoffMs);
+ }
+ }
+ _checkForRetryAfterHeader(headers) {
+ const retryAfter = headers.get("Retry-After");
+ if (!retryAfter) {
+ return null;
+ }
+ const delay = parseInt(retryAfter, 10) * 1000;
+ const tryAgainAfter = new Date().getTime() + delay;
+ if (this.events) {
+ this.events.emit("retry-after", tryAgainAfter);
+ }
+ return delay;
+ }
+}
+
+/**
+ * Endpoints templates.
+ * @type {Object}
+ */
+const ENDPOINTS = {
+ root: () => "/",
+ batch: () => "/batch",
+ permissions: () => "/permissions",
+ bucket: bucket => "/buckets" + (bucket ? `/${bucket}` : ""),
+ history: bucket => `${ENDPOINTS.bucket(bucket)}/history`,
+ collection: (bucket, coll) =>
+ `${ENDPOINTS.bucket(bucket)}/collections` + (coll ? `/${coll}` : ""),
+ group: (bucket, group) =>
+ `${ENDPOINTS.bucket(bucket)}/groups` + (group ? `/${group}` : ""),
+ record: (bucket, coll, id) =>
+ `${ENDPOINTS.collection(bucket, coll)}/records` + (id ? `/${id}` : ""),
+ attachment: (bucket, coll, id) =>
+ `${ENDPOINTS.record(bucket, coll, id)}/attachment`,
+};
+
+const requestDefaults = {
+ safe: false,
+ // check if we should set default content type here
+ headers: {},
+ patch: false,
+};
+/**
+ * @private
+ */
+function safeHeader(safe, last_modified) {
+ if (!safe) {
+ return {};
+ }
+ if (last_modified) {
+ return { "If-Match": `"${last_modified}"` };
+ }
+ return { "If-None-Match": "*" };
+}
+/**
+ * @private
+ */
+function createRequest(path, { data, permissions }, options = {}) {
+ const { headers, safe } = {
+ ...requestDefaults,
+ ...options,
+ };
+ const method = options.method || (data && data.id) ? "PUT" : "POST";
+ return {
+ method,
+ path,
+ headers: { ...headers, ...safeHeader(safe) },
+ body: { data, permissions },
+ };
+}
+/**
+ * @private
+ */
+function updateRequest(path, { data, permissions }, options = {}) {
+ const { headers, safe, patch } = { ...requestDefaults, ...options };
+ const { last_modified } = { ...data, ...options };
+ const hasNoData =
+ data &&
+ Object.keys(data).filter(k => k !== "id" && k !== "last_modified")
+ .length === 0;
+ if (hasNoData) {
+ data = undefined;
+ }
+ return {
+ method: patch ? "PATCH" : "PUT",
+ path,
+ headers: { ...headers, ...safeHeader(safe, last_modified) },
+ body: { data, permissions },
+ };
+}
+/**
+ * @private
+ */
+function jsonPatchPermissionsRequest(path, permissions, opType, options = {}) {
+ const { headers, safe, last_modified } = { ...requestDefaults, ...options };
+ const ops = [];
+ for (const [type, principals] of Object.entries(permissions)) {
+ if (principals) {
+ for (const principal of principals) {
+ ops.push({
+ op: opType,
+ path: `/permissions/${type}/${principal}`,
+ });
+ }
+ }
+ }
+ return {
+ method: "PATCH",
+ path,
+ headers: {
+ ...headers,
+ ...safeHeader(safe, last_modified),
+ "Content-Type": "application/json-patch+json",
+ },
+ body: ops,
+ };
+}
+/**
+ * @private
+ */
+function deleteRequest(path, options = {}) {
+ const { headers, safe, last_modified } = {
+ ...requestDefaults,
+ ...options,
+ };
+ if (safe && !last_modified) {
+ throw new Error("Safe concurrency check requires a last_modified value.");
+ }
+ return {
+ method: "DELETE",
+ path,
+ headers: { ...headers, ...safeHeader(safe, last_modified) },
+ };
+}
+/**
+ * @private
+ */
+function addAttachmentRequest(
+ path,
+ dataURI,
+ { data, permissions } = {},
+ options = {}
+) {
+ const { headers, safe, gzipped } = { ...requestDefaults, ...options };
+ const { last_modified } = { ...data, ...options };
+ const body = { data, permissions };
+ const formData = createFormData(dataURI, body, options);
+ const customPath = `${path}${
+ gzipped !== null ? "?gzipped=" + (gzipped ? "true" : "false") : ""
+ }`;
+ return {
+ method: "POST",
+ path: customPath,
+ headers: { ...headers, ...safeHeader(safe, last_modified) },
+ body: formData,
+ };
+}
+
+/**
+ * Exports batch responses as a result object.
+ *
+ * @private
+ * @param {Array} responses The batch subrequest responses.
+ * @param {Array} requests The initial issued requests.
+ * @return {Object}
+ */
+function aggregate(responses = [], requests = []) {
+ if (responses.length !== requests.length) {
+ throw new Error("Responses length should match requests one.");
+ }
+ const results = {
+ errors: [],
+ published: [],
+ conflicts: [],
+ skipped: [],
+ };
+ return responses.reduce((acc, response, index) => {
+ const { status } = response;
+ const request = requests[index];
+ if (status >= 200 && status < 400) {
+ acc.published.push(response.body);
+ } else if (status === 404) {
+ // Extract the id manually from request path while waiting for Kinto/kinto#818
+ const regex = /(buckets|groups|collections|records)\/([^/]+)$/;
+ const extracts = request.path.match(regex);
+ const id = extracts && extracts.length === 3 ? extracts[2] : undefined;
+ acc.skipped.push({
+ id,
+ path: request.path,
+ error: response.body,
+ });
+ } else if (status === 412) {
+ acc.conflicts.push({
+ // XXX: specifying the type is probably superfluous
+ type: "outgoing",
+ local: request.body,
+ remote:
+ (response.body.details && response.body.details.existing) || null,
+ });
+ } else {
+ acc.errors.push({
+ path: request.path,
+ sent: request,
+ error: response.body,
+ });
+ }
+ return acc;
+ }, results);
+}
+
+// Unique ID creation requires a high quality random # generator. In the browser we therefore
+// require the crypto API and do not support built-in fallback to lower quality random number
+// generators (like Math.random()).
+let getRandomValues;
+const rnds8 = new Uint8Array(16);
+function rng() {
+ // lazy load so that environments that need to polyfill have a chance to do so
+ if (!getRandomValues) {
+ // getRandomValues needs to be invoked in a context where "this" is a Crypto implementation.
+ getRandomValues =
+ typeof crypto !== "undefined" &&
+ crypto.getRandomValues &&
+ crypto.getRandomValues.bind(crypto);
+
+ if (!getRandomValues) {
+ throw new Error(
+ "crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported"
+ );
+ }
+ }
+
+ return getRandomValues(rnds8);
+}
+
+/**
+ * Convert array of 16 byte values to UUID string format of the form:
+ * XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
+ */
+
+const byteToHex = [];
+
+for (let i = 0; i < 256; ++i) {
+ byteToHex.push((i + 0x100).toString(16).slice(1));
+}
+
+function unsafeStringify(arr, offset = 0) {
+ // Note: Be careful editing this code! It's been tuned for performance
+ // and works in ways you may not expect. See https://github.com/uuidjs/uuid/pull/434
+ return (
+ byteToHex[arr[offset + 0]] +
+ byteToHex[arr[offset + 1]] +
+ byteToHex[arr[offset + 2]] +
+ byteToHex[arr[offset + 3]] +
+ "-" +
+ byteToHex[arr[offset + 4]] +
+ byteToHex[arr[offset + 5]] +
+ "-" +
+ byteToHex[arr[offset + 6]] +
+ byteToHex[arr[offset + 7]] +
+ "-" +
+ byteToHex[arr[offset + 8]] +
+ byteToHex[arr[offset + 9]] +
+ "-" +
+ byteToHex[arr[offset + 10]] +
+ byteToHex[arr[offset + 11]] +
+ byteToHex[arr[offset + 12]] +
+ byteToHex[arr[offset + 13]] +
+ byteToHex[arr[offset + 14]] +
+ byteToHex[arr[offset + 15]]
+ ).toLowerCase();
+}
+
+const randomUUID =
+ typeof crypto !== "undefined" &&
+ crypto.randomUUID &&
+ crypto.randomUUID.bind(crypto);
+var native = {
+ randomUUID,
+};
+
+function v4(options, buf, offset) {
+ if (native.randomUUID && !buf && !options) {
+ return native.randomUUID();
+ }
+
+ options = options || {};
+ const rnds = options.random || (options.rng || rng)(); // Per 4.4, set bits for version and `clock_seq_hi_and_reserved`
+
+ rnds[6] = (rnds[6] & 0x0f) | 0x40;
+ rnds[8] = (rnds[8] & 0x3f) | 0x80; // Copy bytes to buffer, if provided
+
+ if (buf) {
+ offset = offset || 0;
+
+ for (let i = 0; i < 16; ++i) {
+ buf[offset + i] = rnds[i];
+ }
+
+ return buf;
+ }
+
+ return unsafeStringify(rnds);
+}
+
+/**
+ * Abstract representation of a selected collection.
+ *
+ */
+class Collection {
+ /**
+ * Constructor.
+ *
+ * @param {KintoClient} client The client instance.
+ * @param {Bucket} bucket The bucket instance.
+ * @param {String} name The collection name.
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Number} [options.retry] The retry option.
+ * @param {Boolean} [options.batch] (Private) Whether this
+ * Collection is operating as part of a batch.
+ */
+ constructor(client, bucket, name, options = {}) {
+ /**
+ * @ignore
+ */
+ this.client = client;
+ /**
+ * @ignore
+ */
+ this.bucket = bucket;
+ /**
+ * The collection name.
+ * @type {String}
+ */
+ this.name = name;
+ this._endpoints = client.endpoints;
+ /**
+ * @ignore
+ */
+ this._retry = options.retry || 0;
+ this._safe = !!options.safe;
+ // FIXME: This is kind of ugly; shouldn't the bucket be responsible
+ // for doing the merge?
+ this._headers = {
+ ...this.bucket.headers,
+ ...options.headers,
+ };
+ }
+ get execute() {
+ return this.client.execute.bind(this.client);
+ }
+ /**
+ * Get the value of "headers" for a given request, merging the
+ * per-request headers with our own "default" headers.
+ *
+ * @private
+ */
+ _getHeaders(options) {
+ return {
+ ...this._headers,
+ ...options.headers,
+ };
+ }
+ /**
+ * Get the value of "safe" for a given request, using the
+ * per-request option if present or falling back to our default
+ * otherwise.
+ *
+ * @private
+ * @param {Object} options The options for a request.
+ * @returns {Boolean}
+ */
+ _getSafe(options) {
+ return { safe: this._safe, ...options }.safe;
+ }
+ /**
+ * As _getSafe, but for "retry".
+ *
+ * @private
+ */
+ _getRetry(options) {
+ return { retry: this._retry, ...options }.retry;
+ }
+ /**
+ * Retrieves the total number of records in this collection.
+ *
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<Number, Error>}
+ */
+ async getTotalRecords(options = {}) {
+ const path = this._endpoints.record(this.bucket.name, this.name);
+ const request = {
+ headers: this._getHeaders(options),
+ path,
+ method: "HEAD",
+ };
+ const { headers } = await this.client.execute(request, {
+ raw: true,
+ retry: this._getRetry(options),
+ });
+ return parseInt(headers.get("Total-Records"), 10);
+ }
+ /**
+ * Retrieves the ETag of the records list, for use with the `since` filtering option.
+ *
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<String, Error>}
+ */
+ async getRecordsTimestamp(options = {}) {
+ const path = this._endpoints.record(this.bucket.name, this.name);
+ const request = {
+ headers: this._getHeaders(options),
+ path,
+ method: "HEAD",
+ };
+ const { headers } = await this.client.execute(request, {
+ raw: true,
+ retry: this._getRetry(options),
+ });
+ return headers.get("ETag");
+ }
+ /**
+ * Retrieves collection data.
+ *
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Object} [options.query] Query parameters to pass in
+ * the request. This might be useful for features that aren't
+ * yet supported by this library.
+ * @param {Array} [options.fields] Limit response to
+ * just some fields.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<Object, Error>}
+ */
+ async getData(options = {}) {
+ const path = this._endpoints.collection(this.bucket.name, this.name);
+ const request = { headers: this._getHeaders(options), path };
+ const { data } = await this.client.execute(request, {
+ retry: this._getRetry(options),
+ query: options.query,
+ fields: options.fields,
+ });
+ return data;
+ }
+ /**
+ * Set collection data.
+ * @param {Object} data The collection data object.
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Boolean} [options.patch] The patch option.
+ * @param {Number} [options.last_modified] The last_modified option.
+ * @return {Promise<Object, Error>}
+ */
+ async setData(data, options = {}) {
+ if (!isObject(data)) {
+ throw new Error("A collection object is required.");
+ }
+ const { patch, permissions } = options;
+ const { last_modified } = { ...data, ...options };
+ const path = this._endpoints.collection(this.bucket.name, this.name);
+ const request = updateRequest(
+ path,
+ { data, permissions },
+ {
+ last_modified,
+ patch,
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ }
+ );
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Retrieves the list of permissions for this collection.
+ *
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<Object, Error>}
+ */
+ async getPermissions(options = {}) {
+ const path = this._endpoints.collection(this.bucket.name, this.name);
+ const request = { headers: this._getHeaders(options), path };
+ const { permissions } = await this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ return permissions;
+ }
+ /**
+ * Replaces all existing collection permissions with the ones provided.
+ *
+ * @param {Object} permissions The permissions object.
+ * @param {Object} [options={}] The options object
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Number} [options.last_modified] The last_modified option.
+ * @return {Promise<Object, Error>}
+ */
+ async setPermissions(permissions, options = {}) {
+ if (!isObject(permissions)) {
+ throw new Error("A permissions object is required.");
+ }
+ const path = this._endpoints.collection(this.bucket.name, this.name);
+ const data = { last_modified: options.last_modified };
+ const request = updateRequest(
+ path,
+ { data, permissions },
+ {
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ }
+ );
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Append principals to the collection permissions.
+ *
+ * @param {Object} permissions The permissions object.
+ * @param {Object} [options={}] The options object
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Object} [options.last_modified] The last_modified option.
+ * @return {Promise<Object, Error>}
+ */
+ async addPermissions(permissions, options = {}) {
+ if (!isObject(permissions)) {
+ throw new Error("A permissions object is required.");
+ }
+ const path = this._endpoints.collection(this.bucket.name, this.name);
+ const { last_modified } = options;
+ const request = jsonPatchPermissionsRequest(path, permissions, "add", {
+ last_modified,
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ });
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Remove principals from the collection permissions.
+ *
+ * @param {Object} permissions The permissions object.
+ * @param {Object} [options={}] The options object
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Object} [options.last_modified] The last_modified option.
+ * @return {Promise<Object, Error>}
+ */
+ async removePermissions(permissions, options = {}) {
+ if (!isObject(permissions)) {
+ throw new Error("A permissions object is required.");
+ }
+ const path = this._endpoints.collection(this.bucket.name, this.name);
+ const { last_modified } = options;
+ const request = jsonPatchPermissionsRequest(path, permissions, "remove", {
+ last_modified,
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ });
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Creates a record in current collection.
+ *
+ * @param {Object} record The record to create.
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Object} [options.permissions] The permissions option.
+ * @return {Promise<Object, Error>}
+ */
+ async createRecord(record, options = {}) {
+ const { permissions } = options;
+ const path = this._endpoints.record(this.bucket.name, this.name, record.id);
+ const request = createRequest(
+ path,
+ { data: record, permissions },
+ {
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ }
+ );
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Adds an attachment to a record, creating the record when it doesn't exist.
+ *
+ * @param {String} dataURL The data url.
+ * @param {Object} [record={}] The record data.
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Number} [options.last_modified] The last_modified option.
+ * @param {Object} [options.permissions] The permissions option.
+ * @param {String} [options.filename] Force the attachment filename.
+ * @param {String} [options.gzipped] Force the attachment to be gzipped or not.
+ * @return {Promise<Object, Error>}
+ */
+ async addAttachment(dataURI, record = {}, options = {}) {
+ const { permissions } = options;
+ const id = record.id || v4();
+ const path = this._endpoints.attachment(this.bucket.name, this.name, id);
+ const { last_modified } = { ...record, ...options };
+ const addAttachmentRequest$1 = addAttachmentRequest(
+ path,
+ dataURI,
+ { data: record, permissions },
+ {
+ last_modified,
+ filename: options.filename,
+ gzipped: options.gzipped,
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ }
+ );
+ await this.client.execute(addAttachmentRequest$1, {
+ stringify: false,
+ retry: this._getRetry(options),
+ });
+ return this.getRecord(id);
+ }
+ /**
+ * Removes an attachment from a given record.
+ *
+ * @param {Object} recordId The record id.
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Number} [options.last_modified] The last_modified option.
+ */
+ async removeAttachment(recordId, options = {}) {
+ const { last_modified } = options;
+ const path = this._endpoints.attachment(
+ this.bucket.name,
+ this.name,
+ recordId
+ );
+ const request = deleteRequest(path, {
+ last_modified,
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ });
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Updates a record in current collection.
+ *
+ * @param {Object} record The record to update.
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Number} [options.last_modified] The last_modified option.
+ * @param {Object} [options.permissions] The permissions option.
+ * @return {Promise<Object, Error>}
+ */
+ async updateRecord(record, options = {}) {
+ if (!isObject(record)) {
+ throw new Error("A record object is required.");
+ }
+ if (!record.id) {
+ throw new Error("A record id is required.");
+ }
+ const { permissions } = options;
+ const { last_modified } = { ...record, ...options };
+ const path = this._endpoints.record(this.bucket.name, this.name, record.id);
+ const request = updateRequest(
+ path,
+ { data: record, permissions },
+ {
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ last_modified,
+ patch: !!options.patch,
+ }
+ );
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Deletes a record from the current collection.
+ *
+ * @param {Object|String} record The record to delete.
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Number} [options.last_modified] The last_modified option.
+ * @return {Promise<Object, Error>}
+ */
+ async deleteRecord(record, options = {}) {
+ const recordObj = toDataBody(record);
+ if (!recordObj.id) {
+ throw new Error("A record id is required.");
+ }
+ const { id } = recordObj;
+ const { last_modified } = { ...recordObj, ...options };
+ const path = this._endpoints.record(this.bucket.name, this.name, id);
+ const request = deleteRequest(path, {
+ last_modified,
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ });
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Deletes records from the current collection.
+ *
+ * Sorting is done by passing a `sort` string option:
+ *
+ * - The field to order the results by, prefixed with `-` for descending.
+ * Default: `-last_modified`.
+ *
+ * @see http://kinto.readthedocs.io/en/stable/api/1.x/sorting.html
+ *
+ * Filtering is done by passing a `filters` option object:
+ *
+ * - `{fieldname: "value"}`
+ * - `{min_fieldname: 4000}`
+ * - `{in_fieldname: "1,2,3"}`
+ * - `{not_fieldname: 0}`
+ * - `{exclude_fieldname: "0,1"}`
+ *
+ * @see http://kinto.readthedocs.io/en/stable/api/1.x/filtering.html
+ *
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Object} [options.filters={}] The filters object.
+ * @param {String} [options.sort="-last_modified"] The sort field.
+ * @param {String} [options.at] The timestamp to get a snapshot at.
+ * @param {String} [options.limit=null] The limit field.
+ * @param {String} [options.pages=1] The number of result pages to aggregate.
+ * @param {Number} [options.since=null] Only retrieve records modified since the provided timestamp.
+ * @param {Array} [options.fields] Limit response to just some fields.
+ * @return {Promise<Object, Error>}
+ */
+ async deleteRecords(options = {}) {
+ const path = this._endpoints.record(this.bucket.name, this.name);
+ return this.client.paginatedDelete(path, options, {
+ headers: this._getHeaders(options),
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Retrieves a record from the current collection.
+ *
+ * @param {String} id The record id to retrieve.
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Object} [options.query] Query parameters to pass in
+ * the request. This might be useful for features that aren't
+ * yet supported by this library.
+ * @param {Array} [options.fields] Limit response to
+ * just some fields.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<Object, Error>}
+ */
+ async getRecord(id, options = {}) {
+ const path = this._endpoints.record(this.bucket.name, this.name, id);
+ const request = { headers: this._getHeaders(options), path };
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ query: options.query,
+ fields: options.fields,
+ });
+ }
+ /**
+ * Lists records from the current collection.
+ *
+ * Sorting is done by passing a `sort` string option:
+ *
+ * - The field to order the results by, prefixed with `-` for descending.
+ * Default: `-last_modified`.
+ *
+ * @see http://kinto.readthedocs.io/en/stable/api/1.x/sorting.html
+ *
+ * Filtering is done by passing a `filters` option object:
+ *
+ * - `{fieldname: "value"}`
+ * - `{min_fieldname: 4000}`
+ * - `{in_fieldname: "1,2,3"}`
+ * - `{not_fieldname: 0}`
+ * - `{exclude_fieldname: "0,1"}`
+ *
+ * @see http://kinto.readthedocs.io/en/stable/api/1.x/filtering.html
+ *
+ * Paginating is done by passing a `limit` option, then calling the `next()`
+ * method from the resolved result object to fetch the next page, if any.
+ *
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Object} [options.filters={}] The filters object.
+ * @param {String} [options.sort="-last_modified"] The sort field.
+ * @param {String} [options.at] The timestamp to get a snapshot at.
+ * @param {String} [options.limit=null] The limit field.
+ * @param {String} [options.pages=1] The number of result pages to aggregate.
+ * @param {Number} [options.since=null] Only retrieve records modified since the provided timestamp.
+ * @param {Array} [options.fields] Limit response to just some fields.
+ * @return {Promise<Object, Error>}
+ */
+ async listRecords(options = {}) {
+ const path = this._endpoints.record(this.bucket.name, this.name);
+ if (options.at) {
+ return this.getSnapshot(options.at);
+ }
+ return this.client.paginatedList(path, options, {
+ headers: this._getHeaders(options),
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * @private
+ */
+ async isHistoryComplete() {
+ // We consider that if we have the collection creation event part of the
+ // history, then all records change events have been tracked.
+ const {
+ data: [oldestHistoryEntry],
+ } = await this.bucket.listHistory({
+ limit: 1,
+ filters: {
+ action: "create",
+ resource_name: "collection",
+ collection_id: this.name,
+ },
+ });
+ return !!oldestHistoryEntry;
+ }
+ /**
+ * @private
+ */
+ async getSnapshot(at) {
+ if (!at || !Number.isInteger(at) || at <= 0) {
+ throw new Error("Invalid argument, expected a positive integer.");
+ }
+ // Retrieve history and check it covers the required time range.
+ // Ensure we have enough history data to retrieve the complete list of
+ // changes.
+ if (!(await this.isHistoryComplete())) {
+ throw new Error(
+ "Computing a snapshot is only possible when the full history for a " +
+ "collection is available. Here, the history plugin seems to have " +
+ "been enabled after the creation of the collection."
+ );
+ }
+ // Because of https://github.com/Kinto/kinto-http.js/issues/963
+ // we cannot simply rely on the history endpoint.
+ // Our strategy here is to clean-up the history entries from the
+ // records that were deleted via the plural endpoint.
+ // We will detect them by comparing the current state of the collection
+ // and the full history of the collection since its genesis.
+ // List full history of collection.
+ const { data: fullHistory } = await this.bucket.listHistory({
+ pages: Infinity,
+ sort: "last_modified",
+ filters: {
+ resource_name: "record",
+ collection_id: this.name,
+ },
+ });
+ // Keep latest entry ever, and latest within snapshot window.
+ // (history is sorted chronologically)
+ const latestEver = new Map();
+ const latestInSnapshot = new Map();
+ for (const entry of fullHistory) {
+ if (entry.target.data.last_modified <= at) {
+ // Snapshot includes changes right on timestamp.
+ latestInSnapshot.set(entry.record_id, entry);
+ }
+ latestEver.set(entry.record_id, entry);
+ }
+ // Current records ids in the collection.
+ const { data: current } = await this.listRecords({
+ pages: Infinity,
+ fields: ["id"], // we don't need attributes.
+ });
+ const currentIds = new Set(current.map(record => record.id));
+ // If a record is not in the current collection, and its
+ // latest history entry isn't a delete then this means that
+ // it was deleted via the plural endpoint (and that we lost track
+ // of this deletion because of bug #963)
+ const deletedViaPlural = new Set();
+ for (const entry of latestEver.values()) {
+ if (entry.action != "delete" && !currentIds.has(entry.record_id)) {
+ deletedViaPlural.add(entry.record_id);
+ }
+ }
+ // Now reconstruct the collection based on latest version in snapshot
+ // filtering all deleted records.
+ const reconstructed = [];
+ for (const entry of latestInSnapshot.values()) {
+ if (entry.action != "delete" && !deletedViaPlural.has(entry.record_id)) {
+ reconstructed.push(entry.target.data);
+ }
+ }
+ return {
+ last_modified: String(at),
+ data: Array.from(reconstructed).sort(
+ (a, b) => b.last_modified - a.last_modified
+ ),
+ next: () => {
+ throw new Error("Snapshots don't support pagination");
+ },
+ hasNextPage: false,
+ totalRecords: reconstructed.length,
+ };
+ }
+ /**
+ * Performs batch operations at the current collection level.
+ *
+ * @param {Function} fn The batch operation function.
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Number} [options.retry] The retry option.
+ * @param {Boolean} [options.aggregate] Produces a grouped result object.
+ * @return {Promise<Object, Error>}
+ */
+ async batch(fn, options = {}) {
+ return this.client.batch(fn, {
+ bucket: this.bucket.name,
+ collection: this.name,
+ headers: this._getHeaders(options),
+ retry: this._getRetry(options),
+ safe: this._getSafe(options),
+ aggregate: !!options.aggregate,
+ });
+ }
+}
+__decorate(
+ [capable(["attachments"])],
+ Collection.prototype,
+ "addAttachment",
+ null
+);
+__decorate(
+ [capable(["attachments"])],
+ Collection.prototype,
+ "removeAttachment",
+ null
+);
+__decorate([capable(["history"])], Collection.prototype, "getSnapshot", null);
+
+/**
+ * Abstract representation of a selected bucket.
+ *
+ */
+class Bucket {
+ /**
+ * Constructor.
+ *
+ * @param {KintoClient} client The client instance.
+ * @param {String} name The bucket name.
+ * @param {Object} [options={}] The headers object option.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Number} [options.retry] The retry option.
+ */
+ constructor(client, name, options = {}) {
+ /**
+ * @ignore
+ */
+ this.client = client;
+ /**
+ * The bucket name.
+ * @type {String}
+ */
+ this.name = name;
+ this._endpoints = client.endpoints;
+ /**
+ * @ignore
+ */
+ this._headers = options.headers || {};
+ this._retry = options.retry || 0;
+ this._safe = !!options.safe;
+ }
+ get execute() {
+ return this.client.execute.bind(this.client);
+ }
+ get headers() {
+ return this._headers;
+ }
+ /**
+ * Get the value of "headers" for a given request, merging the
+ * per-request headers with our own "default" headers.
+ *
+ * @private
+ */
+ _getHeaders(options) {
+ return {
+ ...this._headers,
+ ...options.headers,
+ };
+ }
+ /**
+ * Get the value of "safe" for a given request, using the
+ * per-request option if present or falling back to our default
+ * otherwise.
+ *
+ * @private
+ * @param {Object} options The options for a request.
+ * @returns {Boolean}
+ */
+ _getSafe(options) {
+ return { safe: this._safe, ...options }.safe;
+ }
+ /**
+ * As _getSafe, but for "retry".
+ *
+ * @private
+ */
+ _getRetry(options) {
+ return { retry: this._retry, ...options }.retry;
+ }
+ /**
+ * Selects a collection.
+ *
+ * @param {String} name The collection name.
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Boolean} [options.safe] The safe option.
+ * @return {Collection}
+ */
+ collection(name, options = {}) {
+ return new Collection(this.client, this, name, {
+ headers: this._getHeaders(options),
+ retry: this._getRetry(options),
+ safe: this._getSafe(options),
+ });
+ }
+ /**
+ * Retrieves the ETag of the collection list, for use with the `since` filtering option.
+ *
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<String, Error>}
+ */
+ async getCollectionsTimestamp(options = {}) {
+ const path = this._endpoints.collection(this.name);
+ const request = {
+ headers: this._getHeaders(options),
+ path,
+ method: "HEAD",
+ };
+ const { headers } = await this.client.execute(request, {
+ raw: true,
+ retry: this._getRetry(options),
+ });
+ return headers.get("ETag");
+ }
+ /**
+ * Retrieves the ETag of the group list, for use with the `since` filtering option.
+ *
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<String, Error>}
+ */
+ async getGroupsTimestamp(options = {}) {
+ const path = this._endpoints.group(this.name);
+ const request = {
+ headers: this._getHeaders(options),
+ path,
+ method: "HEAD",
+ };
+ const { headers } = await this.client.execute(request, {
+ raw: true,
+ retry: this._getRetry(options),
+ });
+ return headers.get("ETag");
+ }
+ /**
+ * Retrieves bucket data.
+ *
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Object} [options.query] Query parameters to pass in
+ * the request. This might be useful for features that aren't
+ * yet supported by this library.
+ * @param {Array} [options.fields] Limit response to
+ * just some fields.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<Object, Error>}
+ */
+ async getData(options = {}) {
+ const path = this._endpoints.bucket(this.name);
+ const request = {
+ headers: this._getHeaders(options),
+ path,
+ };
+ const { data } = await this.client.execute(request, {
+ retry: this._getRetry(options),
+ query: options.query,
+ fields: options.fields,
+ });
+ return data;
+ }
+ /**
+ * Set bucket data.
+ * @param {Object} data The bucket data object.
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers={}] The headers object option.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Boolean} [options.patch] The patch option.
+ * @param {Number} [options.last_modified] The last_modified option.
+ * @return {Promise<Object, Error>}
+ */
+ async setData(data, options = {}) {
+ if (!isObject(data)) {
+ throw new Error("A bucket object is required.");
+ }
+ const bucket = {
+ ...data,
+ id: this.name,
+ };
+ // For default bucket, we need to drop the id from the data object.
+ // Bug in Kinto < 3.1.1
+ const bucketId = bucket.id;
+ if (bucket.id === "default") {
+ delete bucket.id;
+ }
+ const path = this._endpoints.bucket(bucketId);
+ const { patch, permissions } = options;
+ const { last_modified } = { ...data, ...options };
+ const request = updateRequest(
+ path,
+ { data: bucket, permissions },
+ {
+ last_modified,
+ patch,
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ }
+ );
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Retrieves the list of history entries in the current bucket.
+ *
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<Array<Object>, Error>}
+ */
+ async listHistory(options = {}) {
+ const path = this._endpoints.history(this.name);
+ return this.client.paginatedList(path, options, {
+ headers: this._getHeaders(options),
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Retrieves the list of collections in the current bucket.
+ *
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.filters={}] The filters object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Array} [options.fields] Limit response to
+ * just some fields.
+ * @return {Promise<Array<Object>, Error>}
+ */
+ async listCollections(options = {}) {
+ const path = this._endpoints.collection(this.name);
+ return this.client.paginatedList(path, options, {
+ headers: this._getHeaders(options),
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Creates a new collection in current bucket.
+ *
+ * @param {String|undefined} id The collection id.
+ * @param {Object} [options={}] The options object.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Object} [options.permissions] The permissions object.
+ * @param {Object} [options.data] The data object.
+ * @return {Promise<Object, Error>}
+ */
+ async createCollection(id, options = {}) {
+ const { permissions, data = {} } = options;
+ data.id = id;
+ const path = this._endpoints.collection(this.name, id);
+ const request = createRequest(
+ path,
+ { data, permissions },
+ {
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ }
+ );
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Deletes a collection from the current bucket.
+ *
+ * @param {Object|String} collection The collection to delete.
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Number} [options.last_modified] The last_modified option.
+ * @return {Promise<Object, Error>}
+ */
+ async deleteCollection(collection, options = {}) {
+ const collectionObj = toDataBody(collection);
+ if (!collectionObj.id) {
+ throw new Error("A collection id is required.");
+ }
+ const { id } = collectionObj;
+ const { last_modified } = { ...collectionObj, ...options };
+ const path = this._endpoints.collection(this.name, id);
+ const request = deleteRequest(path, {
+ last_modified,
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ });
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Deletes collections from the current bucket.
+ *
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.filters={}] The filters object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Array} [options.fields] Limit response to
+ * just some fields.
+ * @return {Promise<Array<Object>, Error>}
+ */
+ async deleteCollections(options = {}) {
+ const path = this._endpoints.collection(this.name);
+ return this.client.paginatedDelete(path, options, {
+ headers: this._getHeaders(options),
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Retrieves the list of groups in the current bucket.
+ *
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.filters={}] The filters object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Array} [options.fields] Limit response to
+ * just some fields.
+ * @return {Promise<Array<Object>, Error>}
+ */
+ async listGroups(options = {}) {
+ const path = this._endpoints.group(this.name);
+ return this.client.paginatedList(path, options, {
+ headers: this._getHeaders(options),
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Fetches a group in current bucket.
+ *
+ * @param {String} id The group id.
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Object} [options.query] Query parameters to pass in
+ * the request. This might be useful for features that aren't
+ * yet supported by this library.
+ * @param {Array} [options.fields] Limit response to
+ * just some fields.
+ * @return {Promise<Object, Error>}
+ */
+ async getGroup(id, options = {}) {
+ const path = this._endpoints.group(this.name, id);
+ const request = {
+ headers: this._getHeaders(options),
+ path,
+ };
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ query: options.query,
+ fields: options.fields,
+ });
+ }
+ /**
+ * Creates a new group in current bucket.
+ *
+ * @param {String|undefined} id The group id.
+ * @param {Array<String>} [members=[]] The list of principals.
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.data] The data object.
+ * @param {Object} [options.permissions] The permissions object.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<Object, Error>}
+ */
+ async createGroup(id, members = [], options = {}) {
+ const data = {
+ ...options.data,
+ id,
+ members,
+ };
+ const path = this._endpoints.group(this.name, id);
+ const { permissions } = options;
+ const request = createRequest(
+ path,
+ { data, permissions },
+ {
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ }
+ );
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Updates an existing group in current bucket.
+ *
+ * @param {Object} group The group object.
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.data] The data object.
+ * @param {Object} [options.permissions] The permissions object.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Number} [options.last_modified] The last_modified option.
+ * @return {Promise<Object, Error>}
+ */
+ async updateGroup(group, options = {}) {
+ if (!isObject(group)) {
+ throw new Error("A group object is required.");
+ }
+ if (!group.id) {
+ throw new Error("A group id is required.");
+ }
+ const data = {
+ ...options.data,
+ ...group,
+ };
+ const path = this._endpoints.group(this.name, group.id);
+ const { patch, permissions } = options;
+ const { last_modified } = { ...data, ...options };
+ const request = updateRequest(
+ path,
+ { data, permissions },
+ {
+ last_modified,
+ patch,
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ }
+ );
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Deletes a group from the current bucket.
+ *
+ * @param {Object|String} group The group to delete.
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Number} [options.last_modified] The last_modified option.
+ * @return {Promise<Object, Error>}
+ */
+ async deleteGroup(group, options = {}) {
+ const groupObj = toDataBody(group);
+ const { id } = groupObj;
+ const { last_modified } = { ...groupObj, ...options };
+ const path = this._endpoints.group(this.name, id);
+ const request = deleteRequest(path, {
+ last_modified,
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ });
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Deletes groups from the current bucket.
+ *
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.filters={}] The filters object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Array} [options.fields] Limit response to
+ * just some fields.
+ * @return {Promise<Array<Object>, Error>}
+ */
+ async deleteGroups(options = {}) {
+ const path = this._endpoints.group(this.name);
+ return this.client.paginatedDelete(path, options, {
+ headers: this._getHeaders(options),
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Retrieves the list of permissions for this bucket.
+ *
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<Object, Error>}
+ */
+ async getPermissions(options = {}) {
+ const request = {
+ headers: this._getHeaders(options),
+ path: this._endpoints.bucket(this.name),
+ };
+ const { permissions } = await this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ return permissions;
+ }
+ /**
+ * Replaces all existing bucket permissions with the ones provided.
+ *
+ * @param {Object} permissions The permissions object.
+ * @param {Object} [options={}] The options object
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Object} [options.headers={}] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Object} [options.last_modified] The last_modified option.
+ * @return {Promise<Object, Error>}
+ */
+ async setPermissions(permissions, options = {}) {
+ if (!isObject(permissions)) {
+ throw new Error("A permissions object is required.");
+ }
+ const path = this._endpoints.bucket(this.name);
+ const { last_modified } = options;
+ const data = { last_modified };
+ const request = updateRequest(
+ path,
+ { data, permissions },
+ {
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ }
+ );
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Append principals to the bucket permissions.
+ *
+ * @param {Object} permissions The permissions object.
+ * @param {Object} [options={}] The options object
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Object} [options.last_modified] The last_modified option.
+ * @return {Promise<Object, Error>}
+ */
+ async addPermissions(permissions, options = {}) {
+ if (!isObject(permissions)) {
+ throw new Error("A permissions object is required.");
+ }
+ const path = this._endpoints.bucket(this.name);
+ const { last_modified } = options;
+ const request = jsonPatchPermissionsRequest(path, permissions, "add", {
+ last_modified,
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ });
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Remove principals from the bucket permissions.
+ *
+ * @param {Object} permissions The permissions object.
+ * @param {Object} [options={}] The options object
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Object} [options.last_modified] The last_modified option.
+ * @return {Promise<Object, Error>}
+ */
+ async removePermissions(permissions, options = {}) {
+ if (!isObject(permissions)) {
+ throw new Error("A permissions object is required.");
+ }
+ const path = this._endpoints.bucket(this.name);
+ const { last_modified } = options;
+ const request = jsonPatchPermissionsRequest(path, permissions, "remove", {
+ last_modified,
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ });
+ return this.client.execute(request, {
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Performs batch operations at the current bucket level.
+ *
+ * @param {Function} fn The batch operation function.
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Number} [options.retry=0] The retry option.
+ * @param {Boolean} [options.aggregate] Produces a grouped result object.
+ * @return {Promise<Object, Error>}
+ */
+ async batch(fn, options = {}) {
+ return this.client.batch(fn, {
+ bucket: this.name,
+ headers: this._getHeaders(options),
+ retry: this._getRetry(options),
+ safe: this._getSafe(options),
+ aggregate: !!options.aggregate,
+ });
+ }
+}
+__decorate([capable(["history"])], Bucket.prototype, "listHistory", null);
+
+/**
+ * Currently supported protocol version.
+ * @type {String}
+ */
+const SUPPORTED_PROTOCOL_VERSION = "v1";
+/**
+ * High level HTTP client for the Kinto API.
+ *
+ * @example
+ * const client = new KintoClient("https://demo.kinto-storage.org/v1");
+ * client.bucket("default")
+ * .collection("my-blog")
+ * .createRecord({title: "First article"})
+ * .then(console.log.bind(console))
+ * .catch(console.error.bind(console));
+ */
+class KintoClientBase {
+ /**
+ * Constructor.
+ *
+ * @param {String} remote The remote URL.
+ * @param {Object} [options={}] The options object.
+ * @param {Boolean} [options.safe=true] Adds concurrency headers to every requests.
+ * @param {EventEmitter} [options.events=EventEmitter] The events handler instance.
+ * @param {Object} [options.headers={}] The key-value headers to pass to each request.
+ * @param {Object} [options.retry=0] Number of retries when request fails (default: 0)
+ * @param {String} [options.bucket="default"] The default bucket to use.
+ * @param {String} [options.requestMode="cors"] The HTTP request mode (from ES6 fetch spec).
+ * @param {Number} [options.timeout=null] The request timeout in ms, if any.
+ * @param {Function} [options.fetchFunc=fetch] The function to be used to execute HTTP requests.
+ */
+ constructor(remote, options) {
+ if (typeof remote !== "string" || !remote.length) {
+ throw new Error("Invalid remote URL: " + remote);
+ }
+ if (remote[remote.length - 1] === "/") {
+ remote = remote.slice(0, -1);
+ }
+ this._backoffReleaseTime = null;
+ this._requests = [];
+ this._isBatch = !!options.batch;
+ this._retry = options.retry || 0;
+ this._safe = !!options.safe;
+ this._headers = options.headers || {};
+ // public properties
+ /**
+ * The remote server base URL.
+ * @type {String}
+ */
+ this.remote = remote;
+ /**
+ * Current server information.
+ * @ignore
+ * @type {Object|null}
+ */
+ this.serverInfo = null;
+ /**
+ * The event emitter instance. Should comply with the `EventEmitter`
+ * interface.
+ * @ignore
+ * @type {Class}
+ */
+ this.events = options.events;
+ this.endpoints = ENDPOINTS;
+ const { fetchFunc, requestMode, timeout } = options;
+ /**
+ * The HTTP instance.
+ * @ignore
+ * @type {HTTP}
+ */
+ this.http = new HTTP(this.events, { fetchFunc, requestMode, timeout });
+ this._registerHTTPEvents();
+ }
+ /**
+ * The remote endpoint base URL. Setting the value will also extract and
+ * validate the version.
+ * @type {String}
+ */
+ get remote() {
+ return this._remote;
+ }
+ /**
+ * @ignore
+ */
+ set remote(url) {
+ let version;
+ try {
+ version = url.match(/\/(v\d+)\/?$/)[1];
+ } catch (err) {
+ throw new Error("The remote URL must contain the version: " + url);
+ }
+ if (version !== SUPPORTED_PROTOCOL_VERSION) {
+ throw new Error(`Unsupported protocol version: ${version}`);
+ }
+ this._remote = url;
+ this._version = version;
+ }
+ /**
+ * The current server protocol version, eg. `v1`.
+ * @type {String}
+ */
+ get version() {
+ return this._version;
+ }
+ /**
+ * Backoff remaining time, in milliseconds. Defaults to zero if no backoff is
+ * ongoing.
+ *
+ * @type {Number}
+ */
+ get backoff() {
+ const currentTime = new Date().getTime();
+ if (this._backoffReleaseTime && currentTime < this._backoffReleaseTime) {
+ return this._backoffReleaseTime - currentTime;
+ }
+ return 0;
+ }
+ /**
+ * Registers HTTP events.
+ * @private
+ */
+ _registerHTTPEvents() {
+ // Prevent registering event from a batch client instance
+ if (!this._isBatch && this.events) {
+ this.events.on("backoff", backoffMs => {
+ this._backoffReleaseTime = backoffMs;
+ });
+ }
+ }
+ /**
+ * Retrieve a bucket object to perform operations on it.
+ *
+ * @param {String} name The bucket name.
+ * @param {Object} [options={}] The request options.
+ * @param {Boolean} [options.safe] The resulting safe option.
+ * @param {Number} [options.retry] The resulting retry option.
+ * @param {Object} [options.headers] The extended headers object option.
+ * @return {Bucket}
+ */
+ bucket(name, options = {}) {
+ return new Bucket(this, name, {
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Set client "headers" for every request, updating previous headers (if any).
+ *
+ * @param {Object} headers The headers to merge with existing ones.
+ */
+ setHeaders(headers) {
+ this._headers = {
+ ...this._headers,
+ ...headers,
+ };
+ this.serverInfo = null;
+ }
+ /**
+ * Get the value of "headers" for a given request, merging the
+ * per-request headers with our own "default" headers.
+ *
+ * Note that unlike other options, headers aren't overridden, but
+ * merged instead.
+ *
+ * @private
+ * @param {Object} options The options for a request.
+ * @returns {Object}
+ */
+ _getHeaders(options) {
+ return {
+ ...this._headers,
+ ...options.headers,
+ };
+ }
+ /**
+ * Get the value of "safe" for a given request, using the
+ * per-request option if present or falling back to our default
+ * otherwise.
+ *
+ * @private
+ * @param {Object} options The options for a request.
+ * @returns {Boolean}
+ */
+ _getSafe(options) {
+ return { safe: this._safe, ...options }.safe;
+ }
+ /**
+ * As _getSafe, but for "retry".
+ *
+ * @private
+ */
+ _getRetry(options) {
+ return { retry: this._retry, ...options }.retry;
+ }
+ /**
+ * Retrieves the server's "hello" endpoint. This endpoint reveals
+ * server capabilities and settings as well as telling the client
+ * "who they are" according to their given authorization headers.
+ *
+ * @private
+ * @param {Object} [options={}] The request options.
+ * @param {Object} [options.headers={}] Headers to use when making
+ * this request.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<Object, Error>}
+ */
+ async _getHello(options = {}) {
+ const path = this.remote + ENDPOINTS.root();
+ const { json } = await this.http.request(
+ path,
+ { headers: this._getHeaders(options) },
+ { retry: this._getRetry(options) }
+ );
+ return json;
+ }
+ /**
+ * Retrieves server information and persist them locally. This operation is
+ * usually performed a single time during the instance lifecycle.
+ *
+ * @param {Object} [options={}] The request options.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<Object, Error>}
+ */
+ async fetchServerInfo(options = {}) {
+ if (this.serverInfo) {
+ return this.serverInfo;
+ }
+ this.serverInfo = await this._getHello({ retry: this._getRetry(options) });
+ return this.serverInfo;
+ }
+ /**
+ * Retrieves Kinto server settings.
+ *
+ * @param {Object} [options={}] The request options.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<Object, Error>}
+ */
+ async fetchServerSettings(options = {}) {
+ const { settings } = await this.fetchServerInfo(options);
+ return settings;
+ }
+ /**
+ * Retrieve server capabilities information.
+ *
+ * @param {Object} [options={}] The request options.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<Object, Error>}
+ */
+ async fetchServerCapabilities(options = {}) {
+ const { capabilities } = await this.fetchServerInfo(options);
+ return capabilities;
+ }
+ /**
+ * Retrieve authenticated user information.
+ *
+ * @param {Object} [options={}] The request options.
+ * @param {Object} [options.headers={}] Headers to use when making
+ * this request.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<Object, Error>}
+ */
+ async fetchUser(options = {}) {
+ const { user } = await this._getHello(options);
+ return user;
+ }
+ /**
+ * Retrieve authenticated user information.
+ *
+ * @param {Object} [options={}] The request options.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<Object, Error>}
+ */
+ async fetchHTTPApiVersion(options = {}) {
+ const { http_api_version } = await this.fetchServerInfo(options);
+ return http_api_version;
+ }
+ /**
+ * Process batch requests, chunking them according to the batch_max_requests
+ * server setting when needed.
+ *
+ * @param {Array} requests The list of batch subrequests to perform.
+ * @param {Object} [options={}] The options object.
+ * @return {Promise<Object, Error>}
+ */
+ async _batchRequests(requests, options = {}) {
+ const headers = this._getHeaders(options);
+ if (!requests.length) {
+ return [];
+ }
+ const serverSettings = await this.fetchServerSettings({
+ retry: this._getRetry(options),
+ });
+ const maxRequests = serverSettings.batch_max_requests;
+ if (maxRequests && requests.length > maxRequests) {
+ const chunks = partition(requests, maxRequests);
+ const results = [];
+ for (const chunk of chunks) {
+ const result = await this._batchRequests(chunk, options);
+ results.push(...result);
+ }
+ return results;
+ }
+ const { responses } = await this.execute(
+ {
+ // FIXME: is this really necessary, since it's also present in
+ // the "defaults"?
+ headers,
+ path: ENDPOINTS.batch(),
+ method: "POST",
+ body: {
+ defaults: { headers },
+ requests,
+ },
+ },
+ { retry: this._getRetry(options) }
+ );
+ return responses;
+ }
+ /**
+ * Sends batch requests to the remote server.
+ *
+ * Note: Reserved for internal use only.
+ *
+ * @ignore
+ * @param {Function} fn The function to use for describing batch ops.
+ * @param {Object} [options={}] The options object.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Number} [options.retry] The retry option.
+ * @param {String} [options.bucket] The bucket name option.
+ * @param {String} [options.collection] The collection name option.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Boolean} [options.aggregate=false] Produces an aggregated result object.
+ * @return {Promise<Object, Error>}
+ */
+ async batch(fn, options = {}) {
+ const rootBatch = new KintoClientBase(this.remote, {
+ events: this.events,
+ batch: true,
+ safe: this._getSafe(options),
+ retry: this._getRetry(options),
+ });
+ if (options.bucket && options.collection) {
+ fn(rootBatch.bucket(options.bucket).collection(options.collection));
+ } else if (options.bucket) {
+ fn(rootBatch.bucket(options.bucket));
+ } else {
+ fn(rootBatch);
+ }
+ const responses = await this._batchRequests(rootBatch._requests, options);
+ if (options.aggregate) {
+ return aggregate(responses, rootBatch._requests);
+ }
+ return responses;
+ }
+ async execute(request, options = {}) {
+ const { raw = false, stringify = true } = options;
+ // If we're within a batch, add the request to the stack to send at once.
+ if (this._isBatch) {
+ this._requests.push(request);
+ // Resolve with a message in case people attempt at consuming the result
+ // from within a batch operation.
+ const msg =
+ "This result is generated from within a batch " +
+ "operation and should not be consumed.";
+ return raw ? { status: 0, json: msg, headers: new Headers() } : msg;
+ }
+ const uri = this.remote + addEndpointOptions(request.path, options);
+ const result = await this.http.request(
+ uri,
+ cleanUndefinedProperties({
+ // Limit requests to only those parts that would be allowed in
+ // a batch request -- don't pass through other fancy fetch()
+ // options like integrity, redirect, mode because they will
+ // break on a batch request. A batch request only allows
+ // headers, method, path (above), and body.
+ method: request.method,
+ headers: request.headers,
+ body: stringify ? JSON.stringify(request.body) : request.body,
+ }),
+ { retry: this._getRetry(options) }
+ );
+ return raw ? result : result.json;
+ }
+ /**
+ * Perform an operation with a given HTTP method on some pages from
+ * a paginated list, following the `next-page` header automatically
+ * until we have processed the requested number of pages. Return a
+ * response with a `.next()` method that can be called to perform
+ * the requested HTTP method on more results.
+ *
+ * @private
+ * @param {String} path
+ * The path to make the request to.
+ * @param {Object} params
+ * The parameters to use when making the request.
+ * @param {String} [params.sort="-last_modified"]
+ * The sorting order to use when doing operation on pages.
+ * @param {Object} [params.filters={}]
+ * The filters to send in the request.
+ * @param {Number} [params.limit=undefined]
+ * The limit to send in the request. Undefined means no limit.
+ * @param {Number} [params.pages=undefined]
+ * The number of pages to operate on. Undefined means one page. Pass
+ * Infinity to operate on everything.
+ * @param {String} [params.since=undefined]
+ * The ETag from which to start doing operation on pages.
+ * @param {Array} [params.fields]
+ * Limit response to just some fields.
+ * @param {Object} [options={}]
+ * Additional request-level parameters to use in all requests.
+ * @param {Object} [options.headers={}]
+ * Headers to use during all requests.
+ * @param {Number} [options.retry=0]
+ * Number of times to retry each request if the server responds
+ * with Retry-After.
+ * @param {String} [options.method="GET"]
+ * The method to use in the request.
+ */
+ async paginatedOperation(path, params = {}, options = {}) {
+ // FIXME: this is called even in batch requests, which doesn't
+ // make any sense (since all batch requests get a "dummy"
+ // response; see execute() above).
+ const { sort, filters, limit, pages, since, fields } = {
+ sort: "-last_modified",
+ ...params,
+ };
+ // Safety/Consistency check on ETag value.
+ if (since && typeof since !== "string") {
+ throw new Error(
+ `Invalid value for since (${since}), should be ETag value.`
+ );
+ }
+ const query = {
+ ...filters,
+ _sort: sort,
+ _limit: limit,
+ _since: since,
+ };
+ if (fields) {
+ query._fields = fields;
+ }
+ const querystring = qsify(query);
+ let results = [],
+ current = 0;
+ const next = async function (nextPage) {
+ if (!nextPage) {
+ throw new Error("Pagination exhausted.");
+ }
+ return processNextPage(nextPage);
+ };
+ const processNextPage = async nextPage => {
+ const { headers } = options;
+ return handleResponse(await this.http.request(nextPage, { headers }));
+ };
+ const pageResults = (results, nextPage, etag) => {
+ // ETag string is supposed to be opaque and stored «as-is».
+ // ETag header values are quoted (because of * and W/"foo").
+ return {
+ last_modified: etag ? etag.replace(/"/g, "") : etag,
+ data: results,
+ next: next.bind(null, nextPage),
+ hasNextPage: !!nextPage,
+ totalRecords: -1,
+ };
+ };
+ const handleResponse = async function ({
+ headers = new Headers(),
+ json = {},
+ }) {
+ const nextPage = headers.get("Next-Page");
+ const etag = headers.get("ETag");
+ if (!pages) {
+ return pageResults(json.data, nextPage, etag);
+ }
+ // Aggregate new results with previous ones
+ results = results.concat(json.data);
+ current += 1;
+ if (current >= pages || !nextPage) {
+ // Pagination exhausted
+ return pageResults(results, nextPage, etag);
+ }
+ // Follow next page
+ return processNextPage(nextPage);
+ };
+ return handleResponse(
+ await this.execute(
+ // N.B.: This doesn't use _getHeaders, because all calls to
+ // `paginatedList` are assumed to come from calls that already
+ // have headers merged at e.g. the bucket or collection level.
+ {
+ headers: options.headers ? options.headers : {},
+ path: path + "?" + querystring,
+ method: options.method,
+ },
+ // N.B. This doesn't use _getRetry, because all calls to
+ // `paginatedList` are assumed to come from calls that already
+ // used `_getRetry` at e.g. the bucket or collection level.
+ { raw: true, retry: options.retry || 0 }
+ )
+ );
+ }
+ /**
+ * Fetch some pages from a paginated list, following the `next-page`
+ * header automatically until we have fetched the requested number
+ * of pages. Return a response with a `.next()` method that can be
+ * called to fetch more results.
+ *
+ * @private
+ * @param {String} path
+ * The path to make the request to.
+ * @param {Object} params
+ * The parameters to use when making the request.
+ * @param {String} [params.sort="-last_modified"]
+ * The sorting order to use when fetching.
+ * @param {Object} [params.filters={}]
+ * The filters to send in the request.
+ * @param {Number} [params.limit=undefined]
+ * The limit to send in the request. Undefined means no limit.
+ * @param {Number} [params.pages=undefined]
+ * The number of pages to fetch. Undefined means one page. Pass
+ * Infinity to fetch everything.
+ * @param {String} [params.since=undefined]
+ * The ETag from which to start fetching.
+ * @param {Array} [params.fields]
+ * Limit response to just some fields.
+ * @param {Object} [options={}]
+ * Additional request-level parameters to use in all requests.
+ * @param {Object} [options.headers={}]
+ * Headers to use during all requests.
+ * @param {Number} [options.retry=0]
+ * Number of times to retry each request if the server responds
+ * with Retry-After.
+ */
+ async paginatedList(path, params = {}, options = {}) {
+ return this.paginatedOperation(path, params, options);
+ }
+ /**
+ * Delete multiple objects, following the pagination if the number of
+ * objects exceeds the page limit until we have deleted the requested
+ * number of pages. Return a response with a `.next()` method that can
+ * be called to delete more results.
+ *
+ * @private
+ * @param {String} path
+ * The path to make the request to.
+ * @param {Object} params
+ * The parameters to use when making the request.
+ * @param {String} [params.sort="-last_modified"]
+ * The sorting order to use when deleting.
+ * @param {Object} [params.filters={}]
+ * The filters to send in the request.
+ * @param {Number} [params.limit=undefined]
+ * The limit to send in the request. Undefined means no limit.
+ * @param {Number} [params.pages=undefined]
+ * The number of pages to delete. Undefined means one page. Pass
+ * Infinity to delete everything.
+ * @param {String} [params.since=undefined]
+ * The ETag from which to start deleting.
+ * @param {Array} [params.fields]
+ * Limit response to just some fields.
+ * @param {Object} [options={}]
+ * Additional request-level parameters to use in all requests.
+ * @param {Object} [options.headers={}]
+ * Headers to use during all requests.
+ * @param {Number} [options.retry=0]
+ * Number of times to retry each request if the server responds
+ * with Retry-After.
+ */
+ paginatedDelete(path, params = {}, options = {}) {
+ const { headers, safe, last_modified } = options;
+ const deleteRequest$1 = deleteRequest(path, {
+ headers,
+ safe: safe ? safe : false,
+ last_modified,
+ });
+ return this.paginatedOperation(path, params, {
+ ...options,
+ headers: deleteRequest$1.headers,
+ method: "DELETE",
+ });
+ }
+ /**
+ * Lists all permissions.
+ *
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers={}] Headers to use when making
+ * this request.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<Object[], Error>}
+ */
+ async listPermissions(options = {}) {
+ const path = ENDPOINTS.permissions();
+ // Ensure the default sort parameter is something that exists in permissions
+ // entries, as `last_modified` doesn't; here, we pick "id".
+ const paginationOptions = { sort: "id", ...options };
+ return this.paginatedList(path, paginationOptions, {
+ headers: this._getHeaders(options),
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Retrieves the list of buckets.
+ *
+ * @param {Object} [options={}] The options object.
+ * @param {Object} [options.headers={}] Headers to use when making
+ * this request.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Object} [options.filters={}] The filters object.
+ * @param {Array} [options.fields] Limit response to
+ * just some fields.
+ * @return {Promise<Object[], Error>}
+ */
+ async listBuckets(options = {}) {
+ const path = ENDPOINTS.bucket();
+ return this.paginatedList(path, options, {
+ headers: this._getHeaders(options),
+ retry: this._getRetry(options),
+ });
+ }
+ /**
+ * Creates a new bucket on the server.
+ *
+ * @param {String|null} id The bucket name (optional).
+ * @param {Object} [options={}] The options object.
+ * @param {Boolean} [options.data] The bucket data option.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @return {Promise<Object, Error>}
+ */
+ async createBucket(id, options = {}) {
+ const { data, permissions } = options;
+ const _data = { ...data, id: id ? id : undefined };
+ const path = _data.id ? ENDPOINTS.bucket(_data.id) : ENDPOINTS.bucket();
+ return this.execute(
+ createRequest(
+ path,
+ { data: _data, permissions },
+ {
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ }
+ ),
+ { retry: this._getRetry(options) }
+ );
+ }
+ /**
+ * Deletes a bucket from the server.
+ *
+ * @ignore
+ * @param {Object|String} bucket The bucket to delete.
+ * @param {Object} [options={}] The options object.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Object} [options.headers] The headers object option.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Number} [options.last_modified] The last_modified option.
+ * @return {Promise<Object, Error>}
+ */
+ async deleteBucket(bucket, options = {}) {
+ const bucketObj = toDataBody(bucket);
+ if (!bucketObj.id) {
+ throw new Error("A bucket id is required.");
+ }
+ const path = ENDPOINTS.bucket(bucketObj.id);
+ const { last_modified } = { ...bucketObj, ...options };
+ return this.execute(
+ deleteRequest(path, {
+ last_modified,
+ headers: this._getHeaders(options),
+ safe: this._getSafe(options),
+ }),
+ { retry: this._getRetry(options) }
+ );
+ }
+ /**
+ * Deletes buckets.
+ *
+ * @param {Object} [options={}] The options object.
+ * @param {Boolean} [options.safe] The safe option.
+ * @param {Object} [options.headers={}] Headers to use when making
+ * this request.
+ * @param {Number} [options.retry=0] Number of retries to make
+ * when faced with transient errors.
+ * @param {Object} [options.filters={}] The filters object.
+ * @param {Array} [options.fields] Limit response to
+ * just some fields.
+ * @param {Number} [options.last_modified] The last_modified option.
+ * @return {Promise<Object[], Error>}
+ */
+ async deleteBuckets(options = {}) {
+ const path = ENDPOINTS.bucket();
+ return this.paginatedDelete(path, options, {
+ headers: this._getHeaders(options),
+ retry: this._getRetry(options),
+ safe: options.safe,
+ last_modified: options.last_modified,
+ });
+ }
+ async createAccount(username, password) {
+ return this.execute(
+ createRequest(
+ `/accounts/${username}`,
+ { data: { password } },
+ { method: "PUT" }
+ )
+ );
+ }
+}
+__decorate(
+ [nobatch("This operation is not supported within a batch operation.")],
+ KintoClientBase.prototype,
+ "fetchServerSettings",
+ null
+);
+__decorate(
+ [nobatch("This operation is not supported within a batch operation.")],
+ KintoClientBase.prototype,
+ "fetchServerCapabilities",
+ null
+);
+__decorate(
+ [nobatch("This operation is not supported within a batch operation.")],
+ KintoClientBase.prototype,
+ "fetchUser",
+ null
+);
+__decorate(
+ [nobatch("This operation is not supported within a batch operation.")],
+ KintoClientBase.prototype,
+ "fetchHTTPApiVersion",
+ null
+);
+__decorate(
+ [nobatch("Can't use batch within a batch!")],
+ KintoClientBase.prototype,
+ "batch",
+ null
+);
+__decorate(
+ [capable(["permissions_endpoint"])],
+ KintoClientBase.prototype,
+ "listPermissions",
+ null
+);
+__decorate(
+ [support("1.4", "2.0")],
+ KintoClientBase.prototype,
+ "deleteBuckets",
+ null
+);
+__decorate(
+ [capable(["accounts"])],
+ KintoClientBase.prototype,
+ "createAccount",
+ null
+);
+
+/*
+ *
+ * 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
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/* @ts-ignore */
+class KintoHttpClient extends KintoClientBase {
+ constructor(remote, options = {}) {
+ const events = {};
+ EventEmitter.decorate(events);
+ super(remote, { events: events, ...options });
+ }
+}
+KintoHttpClient.errors = errors;
+
+export { KintoHttpClient };