UNPKG

@bedrock/web-pouch-edv

Version:
346 lines (305 loc) 9.91 kB
/*! * Copyright (c) 2021-2022 Digital Bazaar, Inc. All rights reserved. */ import {createDatabase, purge} from './pouchdb.js'; import {assert} from './assert.js'; import {parseLocalId} from './helpers.js'; const COLLECTION_NAME = 'edv-storage-doc'; let _client; let _purgeOp; /** * Initializes the encrypted documents database if it has not already * been initialized. * * @returns {Promise} Settles once the operation completes. */ export async function initialize() { if(_client) { // already initialized return; } _client = await createDatabase({name: COLLECTION_NAME}); /* Note: `_id` is populated using the combination of `localEdvId` and `doc.id` and serves as the primary unique index for this collection. Additionally, index information from each encrypted document is massaged to work within the limitations of PouchDB's indexing system. For example, since PouchDB does not support deep referencing fields within arrays (only within objects), the `doc.indexed` array must be transformed to enable indexing here. That array is transformed into three different arrays that are stored at the top level of each record in the database: `attributes` - Holds the names and values of every encrypted attribute and value pair, enabling queries that check full attributes to be checked. `attributeNames` - Holds the names of every encrypted attribute so that queries that just check for the presence of attributes (but not their values) can be performed. `uniqueAttributes` - Holds the names and values of every encrypted attribute that is marked as `unique`. This allows unique constraints to be applied to encrypted attributes. */ // attribute name + value queries await _client.createIndex({ index: { ddoc: 'edv-doc', name: 'attributes', fields: [ 'localEdvId', 'attributes' ], partial_filter_selector: { attributes: {$exists: true} } } }); // attribute name queries await _client.createIndex({ index: { ddoc: 'edv-doc', name: 'attributes.name', fields: [ 'localEdvId', 'attributeNames' ], partial_filter_selector: { attributeNames: {$exists: true} } } }); // used to enforce uniqueness on insert / update await _client.createIndex({ index: { ddoc: 'edv-doc', name: 'attributes.unique', fields: [ 'localEdvId', 'uniqueAttributes' ], partial_filter_selector: { uniqueAttributes: {$exists: true} } } }); // schedule purge op to clean up any deleted docs _schedulePurge(); } /** * Inserts an EDV document. * * @param {object} options - The options to use. * @param {string} options.edvId - The ID of the EDV to store the document in. * @param {object} options.doc - The document to insert. * * @returns {Promise<object>} Resolves to the database record. */ export async function insert({edvId, doc} = {}) { assert.string(edvId, 'edvId'); assert.doc(doc); // create record const {record, uniqueConstraints} = _createRecord({edvId, doc}); // insert and return updated record const result = await _client.insertOne({doc: record, uniqueConstraints}); return result.record; } /** * Updates (replaces) an EDV document. If the document does not exist, it will * be inserted. * * @param {object} options - The options to use. * @param {string} options.edvId - The ID of the EDV to store the document in. * @param {object} options.doc - The document to store. * @param {boolean} [options.deleted=false] - Set to `true` if the EDV * document is a tombstone, i.e., it has been deleted. * * @returns {Promise<object>} Resolves to the database record. */ export async function upsert({edvId, doc, deleted = false} = {}) { assert.string(edvId, 'edvId'); assert.doc(doc); // create record const {localEdvId, record, uniqueConstraints} = _createRecord({edvId, doc}); const _id = _createId({localEdvId, docId: doc.id}); if(deleted) { // mark record as deleted; important to improve pouchdb indexing speed record._deleted = true; } let result; try { result = await _client.updateOne({ doc: record, query: { selector: { _id, 'doc.sequence': doc.sequence - 1 } }, upsert: true, uniqueConstraints }); } catch(e) { if(e.name === 'ConstraintError') { // if the error was with the same document, then the sequence did not // match if(e.existing._id === _id) { const error = new Error( 'Could not update document. Sequence does not match.'); error.name = 'InvalidStateError'; throw error; } } throw e; } if(deleted) { // schedule purge operation to clean up deleted docs _schedulePurge(); } return result.record; } /** * Gets an EDV document. * * @param {object} options - The options to use. * @param {string} options.edvId - The ID of the EDV. * @param {string} options.id - The ID of the document. * * @returns {Promise<object>} Resolves to the database record. */ export async function get({edvId, id} = {}) { assert.string(edvId, 'edvId'); assert.localId(id, 'id'); const {localId: localEdvId} = parseLocalId({id: edvId}); const {docs: [record]} = await _client.find({ selector: {_id: _createId({localEdvId, docId: id})}, limit: 1 }); if(!record) { const error = new Error('Document not found.'); error.name = 'NotFoundError'; throw error; } return record; } /** * Retrieves all EDV documents matching the given query. * * @param {object} options - The options to use. * @param {string} options.edvId - The ID of the EDV. * @param {object} [options.query={}] - The query to use with `selector` and * `options`, etc. * * @returns {Promise<Array>} Resolves with the records that matched the query. */ export async function find({edvId, query = {selector: {}}}) { assert.object(query); assert.object(query.selector); // force local EDV ID to be in query if not present let {selector} = query; if(!selector.localEdvId) { const {localId: localEdvId} = parseLocalId({id: edvId}); selector = {localEdvId, ...selector}; } const {docs: records} = await _client.find({ selector, ...query.options }); return {records}; } /** * Creates a query to pass to `find` based on the given `edvQuery`. The * `edvQuery` can have these properties: `{index, equals, has, count, limit}`. * The `index` property must be given and include an hmac ID associated with an * encrypted index and only one of `equals` or `has` must be given. * * @param {object} options - The options to use. * @param {string} options.edvId - The ID of the EDV. * @param {object} options.edvQuery - The EDV query. * * @returns {Promise<object>} Resolves to a `query` to pass to `find`. */ export function createQuery({edvId, edvQuery} = {}) { assert.string(edvId, 'edvId'); assert.edvQuery(edvQuery, 'edvQuery'); const use_index = ['edv-doc']; const {index, equals, has} = edvQuery; const encodedIndex = encodeURIComponent(index); const {localId: localEdvId} = parseLocalId({id: edvId}); const selector = {localEdvId}; if(equals) { // must provide this to enable use of the attributes query; the PouchDB // query planner needs it to determine start / end keys in the index selector.attributes = {$gt: null}; selector.$or = equals.map(e => ({ attributes: { $all: Object.entries(e).map(([name, value]) => `${encodedIndex}:${encodeURIComponent(name)}:` + `${encodeURIComponent(value)}`) } })); use_index.push('attributes'); } else { // `has` query selector.attributeNames = { $all: has.map(name => `${encodedIndex}:${encodeURIComponent(name)}`) }; use_index.push('attributes.name'); } return {selector, options: {use_index}}; } function _createRecord({edvId, doc}) { const {localId: localEdvId} = parseLocalId({id: edvId}); const _id = _createId({localEdvId, docId: doc.id}); const record = {_id, localEdvId, doc}; // build top-level attribute index fields const { attributes, attributeNames, uniqueAttributes } = _buildAttributesIndex({doc}); if(attributes.length > 0) { record.attributes = attributes; } if(attributeNames.length > 0) { record.attributeNames = attributeNames; } const uniqueConstraints = []; if(uniqueAttributes.length > 0) { record.uniqueAttributes = uniqueAttributes; uniqueConstraints.push({ selector: {localEdvId, uniqueAttributes: {$in: uniqueAttributes}}, options: {use_index: ['edv-doc', 'attributes.unique']} }); } return {localEdvId, record, uniqueConstraints}; } function _buildAttributesIndex({doc}) { const attributes = []; const attributeNames = []; const uniqueAttributes = []; // build top-level index fields if(doc.indexed) { for(const entry of doc.indexed) { if(!entry.attributes) { continue; } const encodedHmacId = encodeURIComponent(entry.hmac.id); for(const attribute of entry.attributes) { // concat hash of hmac ID, name, and value const name = `${encodedHmacId}:${encodeURIComponent(attribute.name)}`; const full = `${name}:${encodeURIComponent(attribute.value)}`; attributes.push(full); attributeNames.push(name); if(attribute.unique) { uniqueAttributes.push(full); } } } } return {attributes, attributeNames, uniqueAttributes}; } function _createId({localEdvId, docId}) { return `${localEdvId}:${docId}`; } function _schedulePurge() { if(_purgeOp) { return; } _purgeOp = purge({name: COLLECTION_NAME}) .catch(e => console.error(e)) .finally(() => _purgeOp = null); }