@bedrock/account
Advanced tools
Comparing version 8.2.0 to 9.0.0
# bedrock-account ChangeLog | ||
## 9.0.0 - 2023-01-24 | ||
### Changed | ||
- **BREAKING**: The database record and layout for this module has changed in | ||
ways that incompatible with any previous releases. Now the uniqueness | ||
constraint for account email addresses is enforced via a proxy collection | ||
and an internal transaction system. This enables the account collection (and | ||
the proxy collection) to be sharded. | ||
### Removed | ||
- **BREAKING**: The `patch` feature in the update API has been removed. Only | ||
overwrite w/sequence matching is permitted. | ||
## 8.2.0 - 2022-12-11 | ||
@@ -4,0 +17,0 @@ |
342
lib/index.js
/*! | ||
* Copyright (c) 2018-2022 Digital Bazaar, Inc. All rights reserved. | ||
* Copyright (c) 2018-2023 Digital Bazaar, Inc. All rights reserved. | ||
*/ | ||
@@ -8,8 +8,5 @@ import * as bedrock from '@bedrock/core'; | ||
import {klona} from 'klona'; | ||
import jsonpatch from 'fast-json-patch'; | ||
import {schema as accountSchema} from '../schemas/bedrock-account.js'; | ||
import {validateInstance} from '@bedrock/validation'; | ||
import {logger} from './logger.js'; | ||
import {RecordCollection} from './RecordCollection.js'; | ||
const {util: {BedrockError}} = bedrock; | ||
// load config defaults | ||
@@ -22,44 +19,11 @@ import './config.js'; | ||
const logger = bedrock.loggers.get('app').child('bedrock-account'); | ||
let ACCOUNT_STORAGE; | ||
bedrock.events.on('bedrock-mongodb.ready', async () => { | ||
await database.openCollections(['account']); | ||
await database.createIndexes([{ | ||
collection: 'account', | ||
fields: {id: 1}, | ||
options: {unique: true, background: false} | ||
}, { | ||
// cover common queries | ||
collection: 'account', | ||
fields: {id: 1, 'meta.status': 1}, | ||
options: {unique: true, background: false} | ||
}, { | ||
// `id` is a prefix to allow for sharding on `id` -- a collection | ||
// cannot be sharded unless its unique indexes have the shard key | ||
// as a prefix; a separate non-unique index is used for lookups | ||
collection: 'account', | ||
fields: {id: 1, 'account.email': 1, 'meta.status': 1}, | ||
options: { | ||
partialFilterExpression: {'account.email': {$exists: true}}, | ||
unique: true, | ||
background: false | ||
} | ||
}, { | ||
collection: 'account', | ||
fields: {'account.email': 1, 'meta.status': 1}, | ||
options: { | ||
partialFilterExpression: {'account.email': {$exists: true}}, | ||
unique: true, | ||
background: false | ||
} | ||
}, { | ||
collection: 'account', | ||
fields: {'account.email': 1}, | ||
options: { | ||
partialFilterExpression: {'account.email': {$exists: true}}, | ||
unique: true, | ||
background: false | ||
} | ||
}]); | ||
ACCOUNT_STORAGE = new RecordCollection({ | ||
collectionName: 'account', | ||
sequenceInData: false, | ||
uniqueFields: ['email'] | ||
}); | ||
await ACCOUNT_STORAGE.initialize(); | ||
}); | ||
@@ -106,28 +70,10 @@ | ||
// insert the account and get the updated record | ||
// prepare the account record | ||
const now = Date.now(); | ||
meta.created = now; | ||
meta.updated = now; | ||
meta.sequence = 0; | ||
let record = { | ||
id: database.hash(account.id), | ||
meta, | ||
account | ||
}; | ||
try { | ||
const result = await database.collections.account.insertOne( | ||
record, database.writeOptions); | ||
record = result.ops[0]; | ||
} catch(e) { | ||
if(!database.isDuplicateError(e)) { | ||
throw e; | ||
} | ||
throw new BedrockError( | ||
'Duplicate account.', | ||
'DuplicateError', { | ||
public: true, | ||
httpStatusCode: 409 | ||
}, e); | ||
} | ||
meta = {...meta, created: now, updated: now, sequence: 0}; | ||
let record = {account, meta}; | ||
// insert the record | ||
record = await ACCOUNT_STORAGE.insert({record}); | ||
// emit `postInsert` event with updated record data | ||
@@ -160,13 +106,17 @@ eventData.account = klona(record.account); | ||
const query = {'meta.status': status}; | ||
const projection = {_id: 0}; | ||
if(id) { | ||
query.id = database.hash(id); | ||
projection.id = 1; | ||
const options = {id}; | ||
if(email !== undefined) { | ||
options.uniqueField = 'email'; | ||
options.uniqueValue = email; | ||
} | ||
if(email) { | ||
query['account.email'] = email; | ||
projection['account.email'] = 1; | ||
try { | ||
// can't use `ACCOUNT_STORAGE.exists`; must check `meta.status` field | ||
const record = await ACCOUNT_STORAGE.get(options); | ||
return record.meta.status === status; | ||
} catch(e) { | ||
if(e.name === 'NotFoundError') { | ||
return false; | ||
} | ||
throw e; | ||
} | ||
return !!await database.collections.account.findOne(query, {projection}); | ||
} | ||
@@ -186,3 +136,3 @@ | ||
*/ | ||
export async function get({id, email, explain} = {}) { | ||
export async function get({id, email, explain = false} = {}) { | ||
assert.optionalString(id, 'id'); | ||
@@ -194,29 +144,16 @@ assert.optionalString(email, 'email'); | ||
const query = {}; | ||
if(id) { | ||
query.id = database.hash(id); | ||
} | ||
if(email) { | ||
query['account.email'] = email; | ||
} | ||
const projection = {_id: 0, account: 1, meta: 1}; | ||
const collection = database.collections.account; | ||
if(explain) { | ||
// 'find().limit(1)' is used here because 'findOne()' doesn't return a | ||
// cursor which allows the use of the explain function. | ||
const cursor = await collection.find(query, {projection}).limit(1); | ||
return cursor.explain('executionStats'); | ||
if(email !== undefined) { | ||
const proxyCollection = ACCOUNT_STORAGE.proxyCollections.get('email'); | ||
return proxyCollection.get({uniqueValue: email, explain}); | ||
} | ||
return ACCOUNT_STORAGE.helper.get({id, explain}); | ||
} | ||
const record = await collection.findOne(query, {projection}); | ||
if(!record) { | ||
throw new BedrockError( | ||
'Account not found.', | ||
'NotFoundError', | ||
{id, httpStatusCode: 404, public: true}); | ||
const options = {id}; | ||
if(email !== undefined) { | ||
options.uniqueField = 'email'; | ||
options.uniqueValue = email; | ||
} | ||
return record; | ||
return ACCOUNT_STORAGE.get(options); | ||
} | ||
@@ -230,6 +167,13 @@ | ||
* @param {object} [options.options={}] - The options (eg: 'sort', 'limit'). | ||
* @param {boolean} [options._allowPending=false] - For internal use only; | ||
* allows finding records that are in the process of being created. | ||
* | ||
* @returns {Promise} Resolves to the records that matched the query. | ||
*/ | ||
export async function getAll({query = {}, options = {}} = {}) { | ||
export async function getAll({ | ||
query = {}, options = {}, _allowPending = false | ||
} = {}) { | ||
if(!_allowPending) { | ||
query = {...query, 'meta.state': {$ne: 'pending'}}; | ||
} | ||
return database.collections.account.find(query, options).toArray(); | ||
@@ -240,6 +184,5 @@ } | ||
* Updates an account by overwriting it with new `account` and / or `meta` | ||
* information or by providing a `patch`. In all cases, the expected | ||
* `sequence` must match the existing account, but if `meta` is being | ||
* overwritten, `sequence` can be omitted and will be auto-computed from | ||
* `meta.sequence`. | ||
* information. In both cases, the expected `sequence` must match the existing | ||
* account, but if `meta` is being overwritten, `sequence` can be omitted and | ||
* the value from `meta.sequence` will be used. | ||
* | ||
@@ -251,7 +194,5 @@ * @param {object} options - The options to use. | ||
* @param {number} [options.sequence] - The sequence number that must match the | ||
* current record prior to the patch if given; can be omitted if no `patch` | ||
* is given and `meta` is given. | ||
* @param {Array} [options.patch] - A JSON patch for performing the update. | ||
* @param {boolean} [options.explain=false] - An optional explain boolean that | ||
* may only be used if `patch` is not provided. | ||
* current record prior to the update if given; can be omitted if `meta` is | ||
* given and has, instead, the new `sequence` number (which must be one more | ||
* than the existing `sequence` number). | ||
* | ||
@@ -261,12 +202,12 @@ * @returns {Promise | ExplainObject} - Returns a Promise that resolves to | ||
*/ | ||
export async function update({ | ||
id, account, meta, sequence, explain, patch | ||
} = {}) { | ||
if(patch) { | ||
if(explain) { | ||
throw new TypeError('"explain" not supported when using "patch".'); | ||
} | ||
return _patchUpdate({id, patch, sequence}); | ||
export async function update({id, account, meta, sequence} = {}) { | ||
if(id === undefined) { | ||
id = account?.id; | ||
} | ||
return _update({id, account, meta, sequence, explain}); | ||
assert.string(id, 'id'); | ||
if(account && account.id !== id) { | ||
throw new TypeError('"id" must equal "account.id".'); | ||
} | ||
return ACCOUNT_STORAGE.update( | ||
{id, data: account, meta, expectedSequence: sequence}); | ||
} | ||
@@ -287,157 +228,8 @@ | ||
const result = await database.collections.account.updateOne( | ||
{id: database.hash(id)}, { | ||
$set: {'meta.status': status}, | ||
$inc: {'meta.sequence': 1} | ||
}, database.writeOptions); | ||
if(result.result.n === 0) { | ||
throw new BedrockError( | ||
'Could not set account status. Account not found.', | ||
'NotFoundError', | ||
{httpStatusCode: 404, account: id, public: true}); | ||
} | ||
const {meta} = await ACCOUNT_STORAGE.get({id}); | ||
meta.status = status; | ||
meta.sequence++; | ||
await ACCOUNT_STORAGE.update({id, meta}); | ||
} | ||
export async function _update({id, account, meta, sequence, explain} = {}) { | ||
// validate params | ||
if(!(account || meta)) { | ||
throw new TypeError('Either "account" or "meta" is required.'); | ||
} | ||
assert.optionalObject(account, 'account'); | ||
assert.optionalObject(meta, 'meta'); | ||
if(id === undefined) { | ||
id = account?.id; | ||
} | ||
assert.string(id, 'id'); | ||
if(account && account.id !== id) { | ||
throw new TypeError('"id" must equal "account.id".'); | ||
} | ||
if(sequence === undefined) { | ||
// use sequence from `meta` | ||
sequence = meta?.sequence - 1; | ||
} | ||
assert.number(sequence, 'sequence'); | ||
if(meta && meta.sequence !== (sequence + 1)) { | ||
throw new TypeError('"sequence" must equal "meta.sequence - 1".'); | ||
} | ||
if(sequence < 0) { | ||
throw new TypeError('"sequence" must be a non-negative integer.'); | ||
} | ||
// build update | ||
const now = Date.now(); | ||
const update = {$set: {}}; | ||
if(account) { | ||
update.$set.account = account; | ||
} | ||
if(meta) { | ||
update.$set.meta = {...meta, updated: now}; | ||
} else { | ||
update.$set['meta.updated'] = now; | ||
update.$set['meta.sequence'] = sequence + 1; | ||
} | ||
const collection = database.collections.account; | ||
const query = { | ||
id: database.hash(id), | ||
'meta.sequence': sequence | ||
}; | ||
if(explain) { | ||
// 'find().limit(1)' is used here because 'updateOne()' doesn't return a | ||
// cursor which allows the use of the explain function. | ||
const cursor = await collection.find(query).limit(1); | ||
return cursor.explain('executionStats'); | ||
} | ||
const result = await collection.updateOne(query, update); | ||
if(result.result.n > 0) { | ||
// record updated | ||
return true; | ||
} | ||
// determine if sequence did not match; will throw if account does not exist | ||
const record = await get({id}); | ||
if(record.meta.sequence !== sequence) { | ||
throw new BedrockError( | ||
'Could not update account. Sequence does not match.', { | ||
name: 'InvalidStateError', | ||
details: { | ||
httpStatusCode: 409, | ||
public: true, | ||
expected: sequence | ||
} | ||
}); | ||
} | ||
return false; | ||
} | ||
async function _patchUpdate({id, patch, sequence} = {}) { | ||
assert.string(id, 'id'); | ||
assert.array(patch, 'patch'); | ||
assert.number(sequence, 'sequence'); | ||
if(sequence < 0) { | ||
throw new TypeError('"sequence" must be a non-negative integer.'); | ||
} | ||
const record = await get({id}); | ||
if(record.meta.sequence !== sequence) { | ||
throw new BedrockError( | ||
'Could not update account. Record sequence does not match.', | ||
'InvalidStateError', { | ||
httpStatusCode: 409, | ||
public: true, | ||
actual: sequence, | ||
expected: record.meta.sequence | ||
}); | ||
} | ||
const customValidate = (operation, index, tree, existingPath) => { | ||
jsonpatch.validator(operation, index, tree, existingPath); | ||
const pathId = /^\/id$/i.test(existingPath); | ||
if(pathId) { | ||
throw new jsonpatch.JsonPatchError( | ||
'"id" cannot be changed', | ||
'OPERATION_OP_INVALID', | ||
index, operation, tree); | ||
} | ||
}; | ||
const errors = jsonpatch.validate(patch, record.account, customValidate); | ||
if(errors) { | ||
throw new BedrockError( | ||
'The given JSON patch is invalid.', | ||
'ValidationError', { | ||
httpStatusCode: 400, | ||
public: true, | ||
patch, | ||
errors | ||
}); | ||
} | ||
// apply patch and validate result | ||
const patched = jsonpatch.applyPatch(record.account, patch).newDocument; | ||
const validationResult = validateInstance( | ||
{instance: patched, schema: accountSchema}); | ||
if(!validationResult.valid) { | ||
throw validationResult.error; | ||
} | ||
const result = await database.collections.account.updateOne({ | ||
id: database.hash(id), | ||
'meta.sequence': sequence | ||
}, { | ||
$set: {account: patched}, | ||
$inc: {'meta.sequence': 1} | ||
}, database.writeOptions); | ||
if(result.result.n === 0) { | ||
return new BedrockError( | ||
'Could not update account. Record sequence does not match.', | ||
'InvalidStateError', {httpStatusCode: 409, public: true}); | ||
} | ||
} | ||
/** | ||
@@ -444,0 +236,0 @@ * An object containing information on the query plan. |
{ | ||
"name": "@bedrock/account", | ||
"version": "8.2.0", | ||
"version": "9.0.0", | ||
"type": "module", | ||
@@ -30,4 +30,4 @@ "description": "User accounts for Bedrock applications", | ||
"assert-plus": "^1.0.0", | ||
"fast-json-patch": "^3.1.1", | ||
"klona": "^2.0.5" | ||
"klona": "^2.0.5", | ||
"uuid": "^9.0.0" | ||
}, | ||
@@ -43,7 +43,7 @@ "peerDependencies": { | ||
"devDependencies": { | ||
"eslint": "^7.32.0", | ||
"eslint-config-digitalbazaar": "^2.8.0", | ||
"eslint-plugin-jsdoc": "^37.9.7", | ||
"jsdoc-to-markdown": "^7.1.1" | ||
"eslint": "^8.30.0", | ||
"eslint-config-digitalbazaar": "^4.2.0", | ||
"eslint-plugin-jsdoc": "^39.6.4", | ||
"jsdoc-to-markdown": "^8.0.0" | ||
} | ||
} |
@@ -5,2 +5,23 @@ # bedrock-account | ||
## API Reference | ||
## Modules | ||
<dl> | ||
<dt><a href="#module_bedrock-account">bedrock-account</a></dt> | ||
<dd></dd> | ||
</dl> | ||
## Typedefs | ||
<dl> | ||
<dt><a href="#ExplainObject">ExplainObject</a> : <code>object</code></dt> | ||
<dd><p>An object containing information on the query plan.</p> | ||
</dd> | ||
<dt><a href="#ExplainObject">ExplainObject</a> : <code>object</code></dt> | ||
<dd><p>An object containing information on the query plan.</p> | ||
</dd> | ||
<dt><a href="#ExplainObject">ExplainObject</a> : <code>object</code></dt> | ||
<dd><p>An object containing information on the query plan.</p> | ||
</dd> | ||
</dl> | ||
<a name="module_bedrock-account"></a> | ||
@@ -13,5 +34,5 @@ | ||
* [.exists(options)](#module_bedrock-account.exists) ⇒ <code>Promise</code> | ||
* [.get(options)](#module_bedrock-account.get) ⇒ <code>Promise</code> | ||
* [.get(options)](#module_bedrock-account.get) ⇒ <code>Promise</code> \| [<code>ExplainObject</code>](#ExplainObject) | ||
* [.getAll(options)](#module_bedrock-account.getAll) ⇒ <code>Promise</code> | ||
* [.update(options)](#module_bedrock-account.update) ⇒ <code>Promise</code> | ||
* [.update(options)](#module_bedrock-account.update) ⇒ <code>Promise</code> \| [<code>ExplainObject</code>](#ExplainObject) | ||
* [.setStatus(options)](#module_bedrock-account.setStatus) ⇒ <code>Promise</code> | ||
@@ -50,12 +71,16 @@ | ||
### bedrock-account.get(options) ⇒ <code>Promise</code> | ||
Retrieves an account. | ||
### bedrock-account.get(options) ⇒ <code>Promise</code> \| [<code>ExplainObject</code>](#ExplainObject) | ||
Retrieves an account by ID or email. | ||
**Kind**: static method of [<code>bedrock-account</code>](#module_bedrock-account) | ||
**Returns**: <code>Promise</code> - Resolves to `{account, meta}`. | ||
**Returns**: <code>Promise</code> \| [<code>ExplainObject</code>](#ExplainObject) - - Returns a Promise that resolves to | ||
the account record (`{account, meta}`) or an ExplainObject if | ||
`explain=true`. | ||
| Param | Type | Description | | ||
| --- | --- | --- | | ||
| options | <code>object</code> | The options to use. | | ||
| options.id | <code>string</code> | The ID of the account to retrieve. | | ||
| Param | Type | Default | Description | | ||
| --- | --- | --- | --- | | ||
| options | <code>object</code> | | The options to use. | | ||
| [options.id] | <code>string</code> | | The ID of the account to retrieve. | | ||
| [options.email] | <code>string</code> | | The email of the account to retrieve. | | ||
| [options.explain] | <code>boolean</code> | <code>false</code> | An optional explain boolean. | | ||
@@ -75,10 +100,15 @@ <a name="module_bedrock-account.getAll"></a> | ||
| [options.options] | <code>object</code> | <code>{}</code> | The options (eg: 'sort', 'limit'). | | ||
| [options._allowPending] | <code>boolean</code> | <code>false</code> | For internal use only; allows finding records that are in the process of being created. | | ||
<a name="module_bedrock-account.update"></a> | ||
### bedrock-account.update(options) ⇒ <code>Promise</code> | ||
Updates an account. | ||
### bedrock-account.update(options) ⇒ <code>Promise</code> \| [<code>ExplainObject</code>](#ExplainObject) | ||
Updates an account by overwriting it with new `account` and / or `meta` | ||
information. In both cases, the expected `sequence` must match the existing | ||
account, but if `meta` is being overwritten, `sequence` can be omitted and | ||
the value from `meta.sequence` will be used. | ||
**Kind**: static method of [<code>bedrock-account</code>](#module_bedrock-account) | ||
**Returns**: <code>Promise</code> - Resolves once the operation completes. | ||
**Returns**: <code>Promise</code> \| [<code>ExplainObject</code>](#ExplainObject) - - Returns a Promise that resolves to | ||
`true` if the update succeeds or an ExplainObject if `explain=true`. | ||
@@ -89,4 +119,5 @@ | Param | Type | Description | | ||
| options.id | <code>string</code> | The ID of the account to update. | | ||
| options.patch | <code>Array</code> | A JSON patch for performing the update. | | ||
| options.sequence | <code>number</code> | The sequence number that must match the current record prior to the patch. | | ||
| [options.account] | <code>object</code> | The new account information to use. | | ||
| [options.meta] | <code>object</code> | The new meta information to use. | | ||
| [options.sequence] | <code>number</code> | The sequence number that must match the current record prior to the update if given; can be omitted if `meta` is given and has, instead, the new `sequence` number (which must be one more than the existing `sequence` number). | | ||
@@ -107,1 +138,19 @@ <a name="module_bedrock-account.setStatus"></a> | ||
<a name="ExplainObject"></a> | ||
## ExplainObject : <code>object</code> | ||
An object containing information on the query plan. | ||
**Kind**: global typedef | ||
<a name="ExplainObject"></a> | ||
## ExplainObject : <code>object</code> | ||
An object containing information on the query plan. | ||
**Kind**: global typedef | ||
<a name="ExplainObject"></a> | ||
## ExplainObject : <code>object</code> | ||
An object containing information on the query plan. | ||
**Kind**: global typedef |
/*! | ||
* Copyright (c) 2018-2022 Digital Bazaar, Inc. All rights reserved. | ||
* Copyright (c) 2018-2023 Digital Bazaar, Inc. All rights reserved. | ||
*/ | ||
@@ -10,3 +10,3 @@ import * as brAccount from '@bedrock/account'; | ||
const newAccount = { | ||
id: 'urn:uuid:' + uuid(), | ||
id: `urn:uuid:${uuid()}`, | ||
@@ -17,2 +17,38 @@ }; | ||
export async function createFakeTransaction({ | ||
accountId, type, committed, _pending, ops = [], skipAccountRecord = false | ||
} = {}) { | ||
const txn = {id: uuid(), type, recordId: accountId}; | ||
if(committed) { | ||
txn.committed = true; | ||
} | ||
if(!skipAccountRecord) { | ||
const query = {'account.id': accountId}; | ||
const update = {$set: {_txn: txn}}; | ||
if(_pending !== undefined) { | ||
update.$set._pending = _pending; | ||
} | ||
const result = await database.collections.account.updateOne( | ||
query, update, {upsert: true}); | ||
result.result.n.should.equal(1); | ||
} | ||
for(const op of ops) { | ||
const _txn = {...txn, op: op.type}; | ||
if(op.type === 'insert') { | ||
const query = {email: op.email}; | ||
const update = {$set: {accountId, email: op.email, _txn}}; | ||
await database.collections['account-email'].updateOne( | ||
query, update, {upsert: true}); | ||
} else { | ||
const query = {email: op.email}; | ||
const update = {$set: {_txn}}; | ||
const result = await database.collections['account-email'].updateOne( | ||
query, update, {upsert: true}); | ||
result.result.n.should.equal(1); | ||
} | ||
} | ||
} | ||
export async function prepareDatabase(mockData) { | ||
@@ -23,3 +59,5 @@ await removeCollections(); | ||
export async function removeCollections(collectionNames = ['account']) { | ||
export async function removeCollections(collectionNames = [ | ||
'account', 'account-email' | ||
]) { | ||
await database.openCollections(collectionNames); | ||
@@ -26,0 +64,0 @@ for(const collectionName of collectionNames) { |
/*! | ||
* Copyright (c) 2018-2022 Digital Bazaar, Inc. All rights reserved. | ||
* Copyright (c) 2018-2023 Digital Bazaar, Inc. All rights reserved. | ||
*/ | ||
@@ -4,0 +4,0 @@ import * as helpers from './helpers.js'; |
@@ -21,3 +21,2 @@ { | ||
"cross-env": "^7.0.2", | ||
"fast-json-patch": "^2.0.6", | ||
"uuid": "^8.3.2" | ||
@@ -24,0 +23,0 @@ }, |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
126792
27
2790
151
1
+ Addeduuid@^9.0.0
+ Addeduuid@9.0.1(transitive)
- Removedfast-json-patch@^3.1.1
- Removedfast-json-patch@3.1.1(transitive)