UNPKG

kinto

Version:

An Offline-First JavaScript client for Kinto.

722 lines (721 loc) 30.7 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __decorate = (this && this.__decorate) || function (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; }; var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); const uuid_1 = require("uuid"); const utils_1 = require("../utils"); const requests = __importStar(require("./requests")); /** * 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 (!(0, utils_1.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 = requests.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 (!(0, utils_1.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 = requests.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 (!(0, utils_1.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 = requests.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 (!(0, utils_1.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 = requests.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 = requests.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. * @return {Promise<Object, Error>} */ async addAttachment(dataURI, record = {}, options = {}) { const { permissions } = options; const id = record.id || (0, uuid_1.v4)(); const path = this._endpoints.attachment(this.bucket.name, this.name, id); const { last_modified } = { ...record, ...options }; const addAttachmentRequest = requests.addAttachmentRequest(path, dataURI, { data: record, permissions }, { last_modified, filename: options.filename, headers: this._getHeaders(options), safe: this._getSafe(options), }); await this.client.execute(addAttachmentRequest, { 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 = requests.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 (!(0, utils_1.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 = requests.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 = (0, utils_1.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 = requests.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, options = {}) { if (!at || !Number.isInteger(at) || at <= 0) { throw new Error("Invalid argument, expected a positive integer."); } let snapshot = null; try { const path = this._endpoints.snapshot(this.bucket.name, this.name, at); const request = { headers: this._getHeaders(options), path, method: "GET", }; const { json } = (await this.client.execute(request, { raw: true, retry: this._getRetry(options), })); snapshot = json; } catch (error) { if (/404/.test(String(error))) { throw error; } } if (snapshot != null) { return { last_modified: String(at), data: snapshot.data, next: () => { throw new Error("Snapshots don't support pagination"); }, hasNextPage: false, totalRecords: snapshot.data.length, }; } // 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, // all pages up to target timestamp are required sort: "last_modified", // chronological order 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, }); } } exports.default = Collection; __decorate([ (0, utils_1.capable)(["attachments"]) ], Collection.prototype, "addAttachment", null); __decorate([ (0, utils_1.capable)(["attachments"]) ], Collection.prototype, "removeAttachment", null); __decorate([ (0, utils_1.capable)(["history"]) ], Collection.prototype, "getSnapshot", null);