diff --git a/__test__/handler-remove-many.spec.ts b/__test__/handler-remove-many.spec.ts index 8f4238a6d..7619e41a7 100644 --- a/__test__/handler-remove-many.spec.ts +++ b/__test__/handler-remove-many.spec.ts @@ -70,7 +70,7 @@ describe('Test Document Remove Many', () => { const Cat = model('Cat', CatSchema); const metadata = getModelMetadata(Cat); try { - await removeCallback('dummy_id', metadata); + await removeCallback('dummy_id', metadata, {}, {}); } catch (err) { const error = err as StatusExecution; const dnf = new DocumentNotFoundError(); diff --git a/__test__/testData.ts b/__test__/testData.ts index 195a31dc3..69a93342a 100644 --- a/__test__/testData.ts +++ b/__test__/testData.ts @@ -1,4 +1,4 @@ -import { Ottoman, SearchConsistency } from '../src'; +import { LogicalWhereExpr, Ottoman, SearchConsistency, FindOptions, ModelTypes } from '../src'; export const bucketName = 'travel-sample'; export const username = 'Administrator'; @@ -18,4 +18,12 @@ export const startInTest = async (ottoman: Ottoman): Promise => { return true; }; +export const cleanUp = ( + model: ModelTypes, + query: LogicalWhereExpr = { _type: model.collectionName }, + options: FindOptions = { consistency: SearchConsistency.LOCAL }, +) => { + return model.removeMany(query, options); +}; + export const consistency = { consistency: SearchConsistency.LOCAL }; diff --git a/__test__/transaction-indexes.spec.ts b/__test__/transaction-indexes.spec.ts new file mode 100644 index 000000000..1c04b8454 --- /dev/null +++ b/__test__/transaction-indexes.spec.ts @@ -0,0 +1,43 @@ +import { Schema, model, getDefaultInstance, SearchConsistency } from '../src'; +import { delay, startInTest } from './testData'; + +test('Testing indexes', async () => { + const UserSchema = new Schema({ + name: String, + email: String, + card: { + cardNumber: String, + zipCode: String, + }, + roles: [{ name: String }], + }); + + UserSchema.index.findN1qlByName = { by: 'name', options: { limit: 4, select: 'name' } }; + + const User = model('TransactionUser7', UserSchema); + const ottoman = getDefaultInstance(); + await startInTest(ottoman); + + const userData = { + name: `index`, + email: 'index@email.com', + card: { cardNumber: '424242425252', zipCode: '42424' }, + roles: [{ name: 'admin' }], + }; + + try { + await ottoman.$transactions(async (ctx) => { + await User.create(userData, { transactionContext: ctx }); + + await delay(2500); + + const usersN1ql = await User.findN1qlByName(userData.name, { transactionContext: ctx }); + expect(usersN1ql.rows[0].name).toBe(userData.name); + }); + } catch (e) { + console.log(e); + } + + const usersN1ql = await User.findN1qlByName(userData.name, { consistency: SearchConsistency.LOCAL }); + expect(usersN1ql.rows[0].name).toBe(userData.name); +}); diff --git a/__test__/transactions.spec.ts b/__test__/transactions.spec.ts new file mode 100644 index 000000000..bc849905b --- /dev/null +++ b/__test__/transactions.spec.ts @@ -0,0 +1,434 @@ +import { getDefaultInstance, model, Schema, SearchConsistency, TransactionAttemptContext } from '../src'; +import { generateUUID } from '../src/utils/generate-uuid'; +import { cleanUp } from './testData'; +import { QueryScanConsistency } from 'couchbase'; + +test('transactions ottoman.query', async () => { + const schema = new Schema({ name: String, age: Number }); + const query = + 'SELECT `name`,`age`,`id`,`_type` FROM `travel-sample`.`_default`.`Duck` WHERE name LIKE "Donald-%" AND _type="Duck"'; + const Duck = model('Duck', schema); + const ottoman = getDefaultInstance(); + await ottoman.start(); + try { + await ottoman.$transactions(async (ctx: TransactionAttemptContext) => { + const name = `Donald-${generateUUID()}`; + const donald = new Duck({ name, age: 89 }); + await donald.save(false, { transactionContext: ctx }); + const list = await ottoman.query(query, { + transactionContext: ctx, + }); + expect(list.rows.length).toBe(1); + }); + } catch (e) { + console.log(e); + } + const list = await ottoman.query(query, { scanConsistency: QueryScanConsistency.RequestPlus }); + expect(list.rows.length).toBe(1); + await cleanUp(Duck); +}); + +test('transactions model.count', async () => { + const schema = new Schema({ name: String, age: Number }); + const Duck = model('Duck', schema); + const otttoman = getDefaultInstance(); + await otttoman.start(); + try { + await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const name = `Daffy-${generateUUID()}`; + const duffy = new Duck({ name, age: 87 }); + await duffy.save(false, { transactionContext: ctx }); + const donalName = `Donald-${generateUUID()}`; + const donald = new Duck({ name: donalName, age: 89 }); + await donald.save(false, { transactionContext: ctx }); + const duckCount = await Duck.count({}, { transactionContext: ctx }); + expect(duckCount).toBe(2); + }); + } catch (e) { + console.log(e); + } + await cleanUp(Duck); +}); + +test('transactions model.removeById', async () => { + const schema = new Schema({ name: String, age: Number }); + const Duck = model('Duck', schema); + const otttoman = getDefaultInstance(); + await otttoman.start(); + try { + await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const name = `Daffy-${generateUUID()}`; + const duffy = new Duck({ name, age: 87 }); + await duffy.save(false, { transactionContext: ctx }); + const donalName = `Donald-${generateUUID()}`; + const donald = new Duck({ name: donalName, age: 89 }); + await donald.save(false, { transactionContext: ctx }); + let duckCount = await Duck.count({}, { transactionContext: ctx }); + expect(duckCount).toBe(2); + await Duck.removeById(donald.id, { transactionContext: ctx }); + duckCount = await Duck.count({}, { transactionContext: ctx }); + expect(duckCount).toBe(1); + }); + } catch (e) { + console.log(e); + } + const duckCount = await Duck.count(); + expect(duckCount).toBe(1); + await cleanUp(Duck); +}); + +test('transactions document.remove', async () => { + const schema = new Schema({ name: String, age: Number }); + const Duck = model('Duck', schema); + const otttoman = getDefaultInstance(); + await otttoman.start(); + try { + await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const name = `Daffy-${generateUUID()}`; + const duffy = new Duck({ name, age: 87 }); + await duffy.save(false, { transactionContext: ctx }); + const donalName = `Donald-${generateUUID()}`; + const donald = new Duck({ name: donalName, age: 89 }); + await donald.save(false, { transactionContext: ctx }); + let duckCount = await Duck.count({}, { transactionContext: ctx }); + expect(duckCount).toBe(2); + await donald.remove({ transactionContext: ctx }); + duckCount = await Duck.count({}, { transactionContext: ctx }); + expect(duckCount).toBe(1); + }); + } catch (e) { + console.log(e); + } + const duckCount = await Duck.count(); + expect(duckCount).toBe(1); + await cleanUp(Duck); +}); + +test('transactions document.save', async () => { + const schema = new Schema({ name: String, age: Number }); + const Duck = model('Duck', schema); + const otttoman = getDefaultInstance(); + await otttoman.start(); + try { + await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const name = `Daffy-${generateUUID()}`; + const duffy = new Duck({ name, age: 87 }); + await duffy.save(false, { transactionContext: ctx }); + expect(duffy.id).toBeDefined(); + expect(duffy.name).toBe(name); + }); + } catch (e) { + console.log(e); + } + await cleanUp(Duck); +}); + +test('transactions model.replaceById', async () => { + const schema = new Schema({ name: String, age: Number }); + let docId; + const newName = `Donald-${generateUUID()}`; + const Duck = model('Duck', schema); + const otttoman = getDefaultInstance(); + await otttoman.start(); + try { + await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const name = `Daffy-${generateUUID()}`; + const duffy = new Duck({ name, age: 87 }); + await duffy.save(false, { transactionContext: ctx }); + docId = duffy.id; + expect(docId).toBeDefined(); + expect(duffy.name).toBe(name); + await Duck.replaceById(docId, { name: newName }, { transactionContext: ctx }); + const modifiedNameDuck = await Duck.findById(docId, { transactionContext: ctx }); + expect(modifiedNameDuck.name).toBe(newName); + }); + } catch (e) { + console.log(e); + } + const modifiedNameDuck = await Duck.findById(docId); + expect(modifiedNameDuck.name).toBe(newName); + await cleanUp(Duck); +}); + +test('transactions model.updateById', async () => { + const schema = new Schema({ name: String, age: Number }); + let docId; + const newName = `Donald-${generateUUID()}`; + const Duck = model('Duck', schema); + const otttoman = getDefaultInstance(); + await otttoman.start(); + try { + await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const name = `Daffy-${generateUUID()}`; + const duffy = new Duck({ name, age: 87 }); + await duffy.save(false, { transactionContext: ctx }); + docId = duffy.id; + expect(docId).toBeDefined(); + expect(duffy.name).toBe(name); + await Duck.updateById(docId, { name: newName }, { transactionContext: ctx }); + const modifiedNameDuck = await Duck.findById(docId, { transactionContext: ctx }); + expect(modifiedNameDuck.name).toBe(newName); + }); + } catch (e) { + console.log(e); + } + const modifiedNameDuck = await Duck.findById(docId); + expect(modifiedNameDuck.name).toBe(newName); + await cleanUp(Duck); +}); + +test('transactions model.find', async () => { + const schema = new Schema({ name: String, age: Number }); + const Swan = model('Swan', schema); + const otttoman = getDefaultInstance(); + await otttoman.start(); + try { + await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const name = `Odette-${generateUUID()}`; + const odette = new Swan({ name, age: 30 }); + await odette.save(false, { transactionContext: ctx }); + // check the document was created in the transaction context + const list = await Swan.find({ name: { $like: 'Odette-%' } }, { transactionContext: ctx }); + expect(list.rows.length).toBe(1); + }); + } catch (e) { + console.log(e); + } + // check the document was successfully committed + const list = await Swan.find({ name: { $like: 'Odette-%' } }, { consistency: SearchConsistency.LOCAL }); + expect(list.rows.length).toBe(1); + await cleanUp(Swan, { name: { $like: 'Odette-%' } }); +}); + +test('transactions model.findOne', async () => { + const schema = new Schema({ name: String, age: Number }); + const Swan = model('Swan', schema); + const filter = { name: { $like: 'Odette-%' } }; + const otttoman = getDefaultInstance(); + await otttoman.start(); + try { + await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const name = `Odette-${generateUUID()}`; + const odette = new Swan({ name, age: 30 }); + await odette.save(false, { transactionContext: ctx }); + // check the document was created in the transaction context + const doc = await Swan.findOne(filter, { transactionContext: ctx }); + expect(doc).toBeDefined(); + expect(doc.id).toBe(odette.id); + }); + } catch (e) { + console.log(e); + } + // check the document was successfully committed + const doc = await Swan.findOne(filter, { consistency: SearchConsistency.LOCAL }); + expect(doc).toBeDefined(); + expect(doc.id).toBeDefined(); + await cleanUp(Swan, filter); +}); + +test('transactions model.findOneAndUpdate', async () => { + const schema = new Schema({ name: String, age: Number }); + const Swan = model('Swan', schema); + const filter = { name: { $like: 'Odette-%' } }; + const otttoman = getDefaultInstance(); + await otttoman.start(); + try { + await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const name = `Odette-${generateUUID()}`; + const odette = new Swan({ name, age: 30 }); + await odette.save(false, { transactionContext: ctx }); + // check the document was created in the transaction context + const doc = await Swan.findOneAndUpdate( + filter, + { name: 'Marie' }, + { consistency: SearchConsistency.LOCAL, transactionContext: ctx, new: true }, + ); + expect(doc).toBeDefined(); + expect(doc.id).toBe(odette.id); + expect(doc.name).toBe('Marie'); + }); + } catch (e) { + console.log(e); + } + // check the document was successfully committed + const doc = await Swan.findOne({ name: 'Marie' }, { consistency: SearchConsistency.LOCAL }); + expect(doc).toBeDefined(); + expect(doc.id).toBeDefined(); + await cleanUp(Swan); +}); + +test('transactions document.populate', async () => { + const eggSchema = new Schema({ name: String, age: Number }); + const Egg = model('Egg', eggSchema); + const duckSchema = new Schema({ name: String, age: Number, eggs: [{ type: eggSchema, ref: 'Egg' }] }); + let duckId; + const Duck = model('Duck', duckSchema); + const otttoman = getDefaultInstance(); + await otttoman.start(); + try { + await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const egg = new Egg({ name: 'Harold', age: -21 }); + await egg.save(false, { transactionContext: ctx }); + const duck = new Duck({ name: 'Elizabeth', age: 30, eggs: [egg.id] }); + await duck.save(false, { transactionContext: ctx }); + duckId = duck.id; + expect(duck.eggs[0]).toBe(egg.id); + // check the document was created in the transaction context + await duck._populate('*', { transactionContext: ctx }); + expect(duck.eggs[0].id).toBe(egg.id); + expect(duck.eggs[0].name).toBe('Harold'); + + // check findById populate to fetch ref from context + const duck2 = await Duck.findById(duck.id, { populate: '*', transactionContext: ctx }); + expect(duck2.eggs[0].id).toBe(egg.id); + expect(duck2.eggs[0].name).toBe('Harold'); + + // check find populate to fetch ref from context + const ducks = await Duck.find({ id: duck.id }, { populate: '*', transactionContext: ctx }); + expect(ducks.rows[0].eggs[0].id).toBe(egg.id); + expect(ducks.rows[0].eggs[0].name).toBe('Harold'); + }); + } catch (e) { + console.log(e); + } + const duck = await Duck.findById(duckId); + expect(duck.eggs[0]).toBeDefined(); + await duck._populate('*'); + expect(duck.eggs[0].name).toBe('Harold'); + await cleanUp(Egg); + await cleanUp(Duck); +}); + +test('transactions model.createMany and model.updateMany', async () => { + const schema = new Schema({ name: String, age: Number }); + const Duck = model('Duck', schema); + const daisy = `Daisy-${generateUUID()}`; + const donald = `Donald-${generateUUID()}`; + const otttoman = getDefaultInstance(); + await otttoman.start(); + try { + await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const name = `Daffy-${generateUUID()}`; + await Duck.createMany( + [ + { name, age: 84 }, + { name: donald, age: 84 }, + { name: daisy, age: 84 }, + ], + { transactionContext: ctx }, + ); + const duckCount = await Duck.count({}, { transactionContext: ctx }); + expect(duckCount).toBe(3); + await Duck.updateMany({ age: 84 }, { name: daisy }, { transactionContext: ctx }); + const list = await Duck.find({ age: 84 }, { transactionContext: ctx }); + expect(list.rows.length).toBe(3); + expect(list.rows).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: donald, + }), + ]), + ); + }); + } catch (e) { + console.log(e); + } + + const list = await Duck.find({ age: 84 }, { consistency: SearchConsistency.LOCAL }); + expect(list.rows.length).toBe(3); + expect(list.rows).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: donald, + }), + ]), + ); + await cleanUp(Duck); +}); + +test('transactions model.createMany and model.removeMany', async () => { + const schema = new Schema({ name: String, age: Number }); + const Duck = model('Duck', schema); + const daisy = `Daisy-${generateUUID()}`; + const donald = `Donald-${generateUUID()}`; + const otttoman = getDefaultInstance(); + await otttoman.start(); + try { + await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const name = `Daffy-${generateUUID()}`; + await Duck.createMany( + [ + { name, age: 84 }, + { name: donald, age: 84 }, + { name: daisy, age: 84 }, + ], + { transactionContext: ctx }, + ); + const duckCount = await Duck.count({ age: 84 }, { transactionContext: ctx }); + expect(duckCount).toBe(3); + await Duck.removeMany({ age: 84 }, { transactionContext: ctx }); + const duckCountAfterRemove = await Duck.count({ age: 84 }, { transactionContext: ctx }); + expect(duckCountAfterRemove).toBe(0); + }); + } catch (e) { + console.log(e); + } + + const duckCountAfterRemove = await Duck.count({ age: 84 }); + expect(duckCountAfterRemove).toBe(0); + await cleanUp(Duck); +}); + +test('transactions rollback', async () => { + const schema = new Schema({ name: String, age: Number }); + const Swan = model('Swan', schema); + const otttoman = getDefaultInstance(); + await otttoman.start(); + try { + await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const name = `Odette-${generateUUID()}`; + const odette = new Swan({ name, age: 30 }); + await odette.save(false, { transactionContext: ctx }); + // check the document was created in the transaction context + const list = await Swan.find({}, { transactionContext: ctx }); + expect(list.rows.length).toBe(1); + await ctx._rollback(); + }); + } catch (e) { + console.log(e); + } + // check the document wasn't committed + const list = await Swan.find({}, { consistency: SearchConsistency.LOCAL }); + expect(list.rows.length).toBe(0); +}); + +test('transactions rollback bulk operation', async () => { + const schema = new Schema({ name: String, age: Number }); + const Duck = model('Duck', schema); + const otttoman = getDefaultInstance(); + await otttoman.start(); + try { + await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const name = `Daffy-${generateUUID()}`; + const daisy = `Daisy-${generateUUID()}`; + const donald = `Donald-${generateUUID()}`; + await Duck.createMany( + [ + { name, age: 84 }, + { name: donald, age: 84 }, + { name: daisy, age: 84 }, + ], + { transactionContext: ctx }, + ); + const duckCount = await Duck.count({ age: 84 }, { transactionContext: ctx }); + expect(duckCount).toBe(3); + await ctx._rollback(); + }); + } catch (e) { + console.log(e); + } + + const list = await Duck.find({ age: 84 }, { consistency: SearchConsistency.LOCAL }); + expect(list.rows.length).toBe(0); + await cleanUp(Duck, { age: 84 }); +}); diff --git a/docusaurus/docs/advanced/transactions.md b/docusaurus/docs/advanced/transactions.md new file mode 100644 index 000000000..d4428f26e --- /dev/null +++ b/docusaurus/docs/advanced/transactions.md @@ -0,0 +1,283 @@ +--- +sidebar_position: 0 +title: Transactions +--- + +A practical guide on using Couchbase Distributed ACID transactions in Ottoman. + +This guide will show you examples of how to perform multi-document ACID (atomic, consistent, isolated, and durable) +database transactions within your application. + +Refer to the [Transaction Concepts](https://docs.couchbase.com/nodejs-sdk/current/concept-docs/transactions.html) concept page for a high-level overview. + +:::info Info + +**Notice:** Transactions support was added in ottoman version `2.5.0`. Please update to `2.5.0` or later to use transactions. + +Ottoman's transactions implementation is intuitive and simple. If you already know how to use Ottoman, you can start working with transactions in no time. + +If not, please check the basics: +- [Ottoman](/docs/basic/ottoman) object +- [Schema](/docs/basic/schema) +- [Model](/docs/basic/model) +- [Document](/docs/basic/document) +::: + +## Creating a Transaction + +To create a transaction, an application must supply its logic inside an arrow function, +including any conditional logic required. Once the arrow function has successfully run to completion, +the transaction will be automatically committed. +If at any point an error occurs, the transaction will rollback and the arrow function may run again. + +```typescript + const schema = new Schema({ name: String, age: Number }); + const Swan = model('Swan', schema); + await start(); + try { + await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const odette = new Swan({ name: 'Odette', age: 30 }); + await odette.save(false, { transactionContext: ctx }); + // check the document was created in the transaction context + const doc = await Swan.findById(odette.id, { transactionContext: ctx }); + console.log(doc); + }); + } catch (error) { + if (error instanceof TransactionFailedError) { + console.error('Transaction did not reach commit point', error) + } + + if (error instanceof TransactionCommitAmbiguousError) { + console.error('Transaction possibly committed', error) + } + } + // check the document was successfully committed + const doc = await Swan.findById(odette.id); + console.log(doc) +``` + +The `$transaction` arrow function gets passed a `TransactionAttemptContext` object--generally referred to as `ctx` in these examples. +Since the arrow function could be rerun multiple times, it is important that it does not contain any side effects. +In particular, you should never perform regular operations on a Collection, such as `create()` without using the `ctx`, inside the arrow function. +Such operations may be performed multiple times, and will not be performed transactionally. +Instead, you should perform these operations by using the `{ transactionContext: ctx }` to pass the Transaction Context. + +In the event that a transaction fails, your application could run into the following errors: +- `TransactionCommitAmbiguousError` +- `TransactionFailedError` + +Refer to [Error Handling](https://docs.couchbase.com/nodejs-sdk/current/concept-docs/transactions-error-handling.html#transaction_errors) for more details on these. + +Methods that currently support transaction context: + +Ottoman: +- `query` + +Model: +- `count` +- `find` +- `findById` +- `findOne` +- `create` +- `createMany` +- `updateById` +- `replaceById` +- `updateMany` +- `removeById` +- `removeMany` +- `findOneAndUpdate` + +Document: +- `save` +- `remove` +- `populate` + + +### Transaction Syntax + +The syntax is pretty simple, just need to define the function to be run by `$transaction`, +then you only need to use the `ctx` parameter as an option for the operations inside the `$transaction` function. + +```typescript +await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const odette = Swan.create({ name: 'Odette', age: 30 }, { transactionContext: ctx }); +}) +``` + +:::tip Tips +The only change you need to add is always to pass the `ctx` in the option `transactionContext` inside `$transaction` function, +this way the operation will know you intend to use it as a transaction, use it as a rule of thumbs up and you will be fine. +::: + +:::warning Pitfall +The **`{ transactionContext: ctx }` option _must_ be passed as a parameter when inside of a `$transaction` function**. Not passing this context will lead to unexpected results, and operations will not function as a transaction. + +Keep a sharp eye on it! +::: + +### Handle Error + +While creating a transaction you always should wrap it inside a `try catch` block and handle the exceptions. + +```typescript +try { + await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const odette = Swan.create({ name: 'Odette', age: 30 }, { transactionContext: ctx }); + }); +} catch (error) { + if (error instanceof TransactionFailedError) { + console.error('Transaction did not reach commit point', error) + } + + if (error instanceof TransactionCommitAmbiguousError) { + console.error('Transaction possibly committed', error) + } +} +``` + +### Concurrent Operations + +The API allows operations to be performed concurrently inside a transaction, which can assist performance. +There are two rules the application needs to follow: +- The first mutation must be performed alone, in serial. This is because the first mutation also triggers the creation of metadata for the transaction. +- All concurrent operations must be allowed to complete fully, so the transaction can track which operations need to be rolled back in the event of failure. This means the application must 'swallow' the error, but record that an error occurred, and then at the end of the concurrent operations, if an error occurred, throw an error to cause the transaction to retry. + +:::tip Note: Query Concurrency +Only one query statement will be performed by the Query service at a time. Non-blocking mechanisms can be used to perform multiple concurrent query statements, but this may result internally in some added network traffic due to retries, and is unlikely to provide any increased performance. +::: + +### Non-Transactional Writes + +To ensure key-value performance is not compromised, and to avoid conflicting writes, applications should never perform non-transactional writes concurrently with transactional ones, on the same document. + +See [Concurrency with Non-Transactional Writes](https://docs.couchbase.com/nodejs-sdk/current/concept-docs/transactions.html#concurrency-with-non-transactional-writes) to learn more. + +### Configuration + +The default configuration should be appropriate for most use cases. +Transactions can optionally be globally configured when configuring the Cluster. +For example, if you want to change the level of durability which that be attained, +this can be configured as part of the connect options: + +```typescript +import { Ottoman } from 'ottoman'; + +const ottoman = new Ottoman(); + +const main = async () => { + await ottoman.connect({ + connectionString: 'couchbase://localhost', + bucketName: 'travel-sample', + username: 'admin', + password: 'password', + transactions: { + durabilityLevel: DurabilityLevel.PersistToMajority, + }, + }); +} + +main(); +``` + +The default configuration will perform all writes with the durability setting `Majority`, +ensuring that each write is available in-memory on the majority of replicas before the transaction continues. +There are two higher durability settings available that will additionally wait for all mutations +to be written to physical storage on either the active or the majority of replicas, +before continuing. This further increases safety, at the cost of additional latency. + +:::warning Caution +A level of `None` is present but its use is discouraged and unsupported. +If durability is set to `None`, then ACID compliance is not guaranteed. +::: + +### Ways of usage + +Inside the `$transaction` function you can do almost everything you can do with Ottoman, for instance: +- `K/V` Operations +- `N1QL` queries +- Combinations between `K/V` and `N1QL` + + +### Examples + +#### Save and retrieve a document inside a transaction, then check it was committed. + +```typescript +const schema = new Schema({ name: String, age: Number }); +const Swan = model('Swan', schema); +await start(); +try { + await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const name = `Odette`; + const odette = new Swan({ name, age: 30 }); + await odette.save(false, { transactionContext: ctx }); + // check the document was created in the transaction context + const list = await Swan.find({ name: 'Odette' }, { transactionContext: ctx }); + }); +} catch (e) { + // Error handling logic goes here. +} +// check the document was successfully committed +const list = await Swan.find({ name: 'Odette' }, { consistency: SearchConsistency.LOCAL }); +``` + +#### Bulk operations + +```typescript +const schema = new Schema({ name: String, age: Number }); +const Duck = model('Duck', schema); +await start(); +try { + await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const daisy = `Daisy`; + const donald = `Donald`; + const daffy = `Daffy`; + // create 3 documents in the current context + await Duck.createMany( + [ + { name: daffy, age: 84 }, + { name: donald, age: 84 }, + { name: daisy, age: 84 }, + ], + { transactionContext: ctx }, + ); + + // execute a count query to check the 3 documents were created in the context + const duckCount = await Duck.count({}, { transactionContext: ctx }); + console.log(duckCount) + + // rename the documents with age = 84 to Daisy + await Duck.updateMany({ age: 84 }, { name: daisy }, { transactionContext: ctx }); + + // query the list of documents to check they were updated as expected + const list = await Duck.find({ age: 84 }, { transactionContext: ctx }); + console.log(list.rows) + }); +} catch (e) { + // Error handling logic goes here. +} + +// query the list of documents to check they were updated and committed +const list = await Duck.find({ age: 84 }, { consistency: SearchConsistency.LOCAL }); +console.log(list.rows) +``` + +### Transactions with RefDoc Indexes +:::danger Pitfall +**RefDoc Indexes are not currently supported with transactions.** Avoid accessing or mutating schemas that are indexed with a RefDoc index within a transaction. Doing so will lead to unexpected results, and operations will not function as a transaction. +::: +Any schema in your Ottoman project that is indexed with a [RefDoc index](/docs/basic/schema#refdoc) should **not be accessed or mutated within a transaction.** For example, if the `Swan` schema is indexed with a RefDoc index, the following code will work, but the transaction will not be atomic: +```typescript +await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + const odette = Swan.create({ name: 'Odette', age: 30 }, { transactionContext: ctx }); +}) +``` +It is acceptable to access _other_ schemas that are **not** indexed with a RefDoc index within a transaction. Ottoman will warn you if your project has **any** refdoc indexes when you attempt to use transactions, but it is up to you to ensure that you do not access or mutate these particular schemas within a transaction. + + + +### Additional Resources + +- Check the Couchbase Node.JS SDK [transaction documentation](https://docs.couchbase.com/nodejs-sdk/current/howtos/distributed-acid-transactions-from-the-sdk.html). +- Learn more about [Distributed ACID Transactions](https://docs.couchbase.com/nodejs-sdk/current/concept-docs/transactions.html). +- Check out the SDK [API Reference](https://docs.couchbase.com/sdk-api/couchbase-node-client/index.html). diff --git a/docusaurus/docs/basic/ottoman.md b/docusaurus/docs/basic/ottoman.md index 718e7684a..c05aa9985 100644 --- a/docusaurus/docs/basic/ottoman.md +++ b/docusaurus/docs/basic/ottoman.md @@ -125,7 +125,7 @@ const main = async () => { connectionString: 'couchbase://localhost', bucketName: 'travel-sample', username: 'admin', - password: 'password' + password: 'password', }); } diff --git a/docusaurus/docs/basic/schema.md b/docusaurus/docs/basic/schema.md index 9d9f8a440..a53b2433a 100644 --- a/docusaurus/docs/basic/schema.md +++ b/docusaurus/docs/basic/schema.md @@ -452,10 +452,14 @@ console.log(userRefdoc); } ``` +:::danger +**RefDoc Indexes** are not currently supported with transactions. If you plan to use transactions, see [Ottoman Transactions](/docs/advanced/transactions#transactions-with-refdoc-indexes) for more information. +::: + :::caution -**Refdoc Index** is not managed by Couchbase but strictly by Ottoman. It does not guarantee consistency if the keys that are a part of these indexes are updated by an external operation, like N1QL for example. +**RefDoc Indexes** are not managed by Couchbase but strictly by Ottoman. It does not guarantee consistency if the keys that are a part of these indexes are updated by an external operation, like N1QL for example. -**_Needs to be used with caution!!!_** +**_Please use with caution!_** ::: ### View diff --git a/docusaurus/docusaurus.config.js b/docusaurus/docusaurus.config.js index 3cab34925..4892feb00 100644 --- a/docusaurus/docusaurus.config.js +++ b/docusaurus/docusaurus.config.js @@ -74,6 +74,7 @@ const config = { label: 'Advanced', items: [ { label: 'Full Text Search', to: '/docs/advanced/fts' }, + { label: 'Transactions', to: '/docs/advanced/transactions' }, { label: 'How Ottoman Works', to: '/docs/advanced/how-ottoman-works' }, { label: 'Ottoman', to: '/docs/advanced/ottoman' }, { label: 'Mongoose to Ottoman', to: '/docs/advanced/mongoose-to-couchbase' }, diff --git a/package.json b/package.json index 44799f798..a04783e27 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "lint": "eslint '*/**/*.ts' --ignore-pattern '/lib/*' --quiet --fix", "test": "jest --clearCache && jest -i", "test:legacy": "jest --clearCache && OTTOMAN_LEGACY_TEST=1 jest -i", - "test:coverage": "jest --clearCache && OTTOMAN_LEGACY_TEST=1 jest -i --coverage", + "test:coverage": "jest --clearCache && jest -i --coverage", "test:dev": "jest --watch", "test:ready": "jest --clearCache && jest -i --coverage", "prepare": "husky install" diff --git a/src/couchbase.ts b/src/couchbase.ts index 1301ae4a8..3c6ae15e3 100644 --- a/src/couchbase.ts +++ b/src/couchbase.ts @@ -25,4 +25,5 @@ export { SearchQuery, SearchScanConsistency, TermSearchFacet, + TransactionAttemptContext, } from 'couchbase'; diff --git a/src/handler/find/find-by-id-options.ts b/src/handler/find/find-by-id-options.ts index 4f2c4eb02..d2926db0f 100644 --- a/src/handler/find/find-by-id-options.ts +++ b/src/handler/find/find-by-id-options.ts @@ -1,4 +1,7 @@ +import { TransactionAttemptContext } from 'couchbase'; + export class FindByIdOptions { + transactionContext?: TransactionAttemptContext; select?: string | string[]; populate?: string | string[]; withExpiry?: boolean; diff --git a/src/handler/find/find-options.ts b/src/handler/find/find-options.ts index 2f6dfb47b..3cd4c6097 100644 --- a/src/handler/find/find-options.ts +++ b/src/handler/find/find-options.ts @@ -1,6 +1,7 @@ import { PopulateFieldsType } from '../../model/populate.types'; import { ISelectType, SortType } from '../../query'; import { SearchConsistency } from '../../utils/search-consistency'; +import { TransactionAttemptContext } from 'couchbase'; export class FindOptions implements IFindOptions { skip?: number; @@ -22,6 +23,7 @@ export class FindOptions implements IFindOptions { lean?: boolean; ignoreCase?: boolean; enforceRefCheck?: boolean | 'throw'; + transactionContext?: TransactionAttemptContext; constructor(data: FindOptions) { for (const key in data) { this[key] = data[key]; diff --git a/src/handler/find/find.ts b/src/handler/find/find.ts index 25be0ec82..6b356dc2a 100644 --- a/src/handler/find/find.ts +++ b/src/handler/find/find.ts @@ -8,6 +8,7 @@ import { getProjectionFields } from '../../utils/query/extract-select'; import { isNumber } from '../../utils/type-helpers'; import { FindOptions } from './find-options'; import { MODEL_KEY } from '../../utils/constants'; +import { TransactionQueryOptions } from 'couchbase'; /** * Find documents using filter and options. @@ -28,6 +29,7 @@ export const find = lean, ignoreCase, enforceRefCheck, + transactionContext, } = options; const { ottoman, collectionName, modelKey, scopeName, modelName, ID_KEY, schema } = metadata; const { bucketName, cluster, couchbase } = ottoman; @@ -98,7 +100,12 @@ export const find = if (isDebugMode()) { console.log(n1ql); } - const result = await cluster.query(n1ql, queryOptions); + let result; + if (transactionContext) { + result = await transactionContext.query(n1ql, queryOptions as TransactionQueryOptions); + } else { + result = await cluster.query(n1ql, queryOptions); + } if (direct) return result; @@ -111,6 +118,7 @@ export const find = modelName, fieldsName: populate, enforceRefCheck, + transactionContext, } as PopulateAuxOptionsType; result.rows = await Promise.all(result.rows.map((pojo) => getPopulated({ ...params, pojo }))); } else { diff --git a/src/handler/remove-many.ts b/src/handler/remove-many.ts index 252cc679f..8143af32f 100644 --- a/src/handler/remove-many.ts +++ b/src/handler/remove-many.ts @@ -12,18 +12,18 @@ import { ManyQueryResponse, StatusExecution } from './types'; */ export const removeMany = (metadata: ModelMetadata) => - async (ids): Promise => { - return await batchProcessQueue(metadata)(ids, removeCallback, {}, {}, 100); + async (ids, options = {}): Promise => { + return await batchProcessQueue(metadata)(ids, removeCallback, {}, options, 100); }; /** * @ignore */ -export const removeCallback = (id: string, metadata: ModelMetadata): Promise => { +export const removeCallback = (id: string, metadata: ModelMetadata, extra, options): Promise => { const model = metadata.ottoman.getModel(metadata.modelName); return model - .removeById(id) + .removeById(id, options) .then(() => { return Promise.resolve(new StatusExecution(id, 'SUCCESS')); }) diff --git a/src/handler/remove.ts b/src/handler/remove.ts index 1537096ea..ca29e082a 100644 --- a/src/handler/remove.ts +++ b/src/handler/remove.ts @@ -1,10 +1,18 @@ +import { TransactionAttemptContext } from 'couchbase'; + interface RemoveOptions { timeout?: number; + transactionContext?: TransactionAttemptContext; } /** * Removes a document by id from a given collection. */ -export const remove = (id, collection, options?: RemoveOptions): Promise => { +export const remove = async (id, collection, options?: RemoveOptions): Promise => { + const { transactionContext } = options || {}; + if (transactionContext) { + const doc = await transactionContext.get(collection, id); + return transactionContext.remove(doc); + } return collection.remove(id, options); }; diff --git a/src/handler/store.ts b/src/handler/store.ts index e1c83de2a..dd5be6262 100644 --- a/src/handler/store.ts +++ b/src/handler/store.ts @@ -1,25 +1,38 @@ +import { TransactionAttemptContext } from 'couchbase'; + interface StoreOptions { cas?: string; transcoder?: any; timeout?: number; maxExpiry?: number; expiry?: number; + transactionContext?: TransactionAttemptContext; } /** * Stores a Document: Updates a document if CAS value is defined, otherwise it inserts a new document. * CAS is a value representing the current state of an item/document in the Couchbase Server. Each modification of the document changes it's CAS value. */ -export const store = (key, data, options: StoreOptions, collection): Promise => { +export const store = async (key, data, options: StoreOptions, collection): Promise => { let storePromise; + const { transactionContext } = options || {}; if (options.maxExpiry !== undefined) { options.expiry = options.maxExpiry; delete options.maxExpiry; } if (options.cas) { - storePromise = collection.replace(key, data, options); + if (transactionContext) { + const doc = await transactionContext.get(collection, key); + storePromise = transactionContext.replace(doc, data); + } else { + storePromise = collection.replace(key, data, options); + } } else { - storePromise = collection.insert(key, data, options); + if (options.transactionContext) { + storePromise = options.transactionContext.insert(collection, key, data); + } else { + storePromise = collection.insert(key, data, options); + } } return storePromise; }; diff --git a/src/model/create-model.ts b/src/model/create-model.ts index 375d58ae5..d0ffcf654 100644 --- a/src/model/create-model.ts +++ b/src/model/create-model.ts @@ -17,7 +17,7 @@ import { CreateModel } from './interfaces/create-model.interface'; import { FindOneAndUpdateOption } from './interfaces/find.interface'; import { ModelMetadata } from './interfaces/model-metadata.interface'; import { UpdateManyOptions } from './interfaces/update-many.interface'; -import { ModelTypes, saveOptions } from './model.types'; +import { ModelTypes, saveOptions, CountOptions, CountOptions as removeOptions } from './model.types'; import { getModelMetadata, getPopulated, setModelMetadata } from './utils/model.utils'; import { mergeDoc } from '../utils/merge'; @@ -144,6 +144,10 @@ export const _buildModel = (metadata: ModelMetadata) => { return ottoman.bucketName; } + static get collectionName(): string { + return metadata.collectionName; + } + static query(params: IConditionExpr): Query { return new Query(params, this.namespace); } @@ -170,11 +174,12 @@ export const _buildModel = (metadata: ModelMetadata) => { return ottoman.dropCollection(_scopeName, _collectionName, options); } - static count = async (filter: LogicalWhereExpr = {}) => { + static count = async (filter: LogicalWhereExpr = {}, options: CountOptions = {}) => { const response = await find(metadata)(filter, { select: 'RAW COUNT(*) as count', noCollection: true, consistency: SearchConsistency.LOCAL, + transactionContext: options.transactionContext, }); if (response.hasOwnProperty('rows') && response.rows.length > 0) { return response.rows[0]; @@ -183,7 +188,15 @@ export const _buildModel = (metadata: ModelMetadata) => { }; static findById = async (id: string, options: FindByIdOptions = {}): Promise> => { - const { populate, populateMaxDeep: deep, select, lean, enforceRefCheck = false, ...findOptions } = options; + const { + populate, + populateMaxDeep: deep, + select, + lean, + transactionContext, + enforceRefCheck = false, + ...findOptions + } = options; const modelKeyClean = modelKey.split('.')[0]; let isModelKeyAddedToSelect = false; if (select) { @@ -195,7 +208,15 @@ export const _buildModel = (metadata: ModelMetadata) => { findOptions['project'] = extractSelect(selectArray, { noCollection: true }, false, modelKey.split('.')[0]); } const key = _keyGenerator!(keyGenerator, { metadata, id }, keyGeneratorDelimiter); - const { value: pojo } = await collection().get(key, findOptions); + + let pojo; + if (transactionContext) { + const { content } = await transactionContext.get(collection(), key); + pojo = content; + } else { + const { value } = await collection().get(key, findOptions); + pojo = value; + } if (getValueByPath(pojo, metadata.modelKey) !== metadata.modelName) { throw new DocumentNotFoundError(); @@ -206,7 +227,17 @@ export const _buildModel = (metadata: ModelMetadata) => { } if (populate) { - return getPopulated({ fieldsName: populate, deep, lean, pojo, schema, modelName, ottoman, enforceRefCheck }); + return getPopulated({ + fieldsName: populate, + deep, + lean, + pojo, + schema, + modelName, + ottoman, + enforceRefCheck, + transactionContext, + }); } if (lean) return pojo; const ModelFactory = ottoman.getModel(modelName); @@ -243,7 +274,10 @@ export const _buildModel = (metadata: ModelMetadata) => { throw new Error(`data contains id field with different value to the id provided! -> ${id} != ${data[ID_KEY]}`); } const key = id || data[ID_KEY]; - const value = await _Model.findById(key, { withExpiry: !!options.maxExpiry }); + const value = await _Model.findById(key, { + withExpiry: !!options.maxExpiry, + transactionContext: options.transactionContext, + }); if (value[ID_KEY]) { const strategy = CAST_STRATEGY.THROW; const obj = mergeDoc(value, data); @@ -253,13 +287,19 @@ export const _buildModel = (metadata: ModelMetadata) => { if (options.maxExpiry) { _options.maxExpiry = options.maxExpiry; } + if (options.transactionContext) { + _options.transactionContext = options.transactionContext; + } return instance.save(false, options); } }; static replaceById = async (id: string, data, options: MutationFunctionOptions = { strict: true }) => { const key = id || data[ID_KEY]; - const value = await _Model.findById(key, { withExpiry: !!options.maxExpiry }); + const value = await _Model.findById(key, { + withExpiry: !!options.maxExpiry, + transactionContext: options.transactionContext, + }); if (value[ID_KEY]) { const temp = {}; Object.keys(data).map((key) => { @@ -290,6 +330,9 @@ export const _buildModel = (metadata: ModelMetadata) => { if (options.maxExpiry) { _options.maxExpiry = options.maxExpiry; } + if (options.transactionContext) { + _options.transactionContext = options.transactionContext; + } if (options.hasOwnProperty('enforceRefCheck')) { _options.enforceRefCheck = options.enforceRefCheck; } @@ -297,11 +340,11 @@ export const _buildModel = (metadata: ModelMetadata) => { } }; - static removeById = (id: string) => { + static removeById = (id: string, options: removeOptions = {}) => { const modelKeyObj = {}; setValueByPath(modelKeyObj, modelKey, modelName); const instance = new _Model({ ...{ [ID_KEY]: id, ...modelKeyObj } }); - return instance.remove(); + return instance.remove(options); }; static fromData(data: Record): _Model { @@ -311,7 +354,10 @@ export const _buildModel = (metadata: ModelMetadata) => { static removeMany = async (filter: LogicalWhereExpr = {}, options: FindOptions = {}) => { const response = await find(metadata)(filter, options); if (response.hasOwnProperty('rows') && response.rows.length > 0) { - return removeMany(metadata)(response.rows.map((v) => v[ID_KEY])); + return removeMany(metadata)( + response.rows.map((v) => v[ID_KEY]), + options.transactionContext ? { transactionContext: options.transactionContext } : {}, + ); } return new ManyQueryResponse('SUCCESS', { match_number: 0, success: 0, errors: [], data: [] }); }; @@ -346,6 +392,9 @@ export const _buildModel = (metadata: ModelMetadata) => { if (options.maxExpiry) { saveOptions.maxExpiry = options.maxExpiry; } + if (options.transactionContext) { + saveOptions.transactionContext = options.transactionContext; + } if (options.hasOwnProperty('enforceRefCheck')) { saveOptions.enforceRefCheck = options.enforceRefCheck; } @@ -365,10 +414,10 @@ export const _buildModel = (metadata: ModelMetadata) => { } }; - static findOneAndRemove = async (filter: LogicalWhereExpr = {}) => { - const doc = await _Model.findOne(filter, { consistency: 1 }); + static findOneAndRemove = async (filter: LogicalWhereExpr = {}, options: removeOptions = {}) => { + const doc = await _Model.findOne(filter, { ...options, consistency: 1 }); if (doc) { - await doc.remove(); + await doc.remove(options); return doc; } throw new DocumentNotFoundError(); diff --git a/src/model/document.ts b/src/model/document.ts index 33f86df0c..396c90113 100644 --- a/src/model/document.ts +++ b/src/model/document.ts @@ -7,7 +7,7 @@ import { extractDataFromModel } from '../utils/extract-data-from-model'; import { generateUUID } from '../utils/generate-uuid'; import { extractSchemaReferencesFields, extractSchemaReferencesFromGivenFields } from '../utils/schema.utils'; import { ModelMetadata } from './interfaces/model-metadata.interface'; -import { saveOptions } from './model.types'; +import { removeOptions, saveOptions } from './model.types'; import { PopulateFieldsType, PopulateOptionsType } from './populate.types'; import { arrayDiff } from './utils/array-diff'; import { getModelRefKeys } from './utils/get-model-ref-keys'; @@ -169,7 +169,10 @@ export abstract class Document { } else { try { key = _keyGenerator!(keyGenerator, { metadata, id }, keyGeneratorDelimiter); - const { cas, value: oldData } = await collection().get(key); + + const { cas, value: oldData } = await (options.transactionContext + ? options.transactionContext.get(collection(), key) + : collection().get(key)); if (cas && onlyCreate) { throw new DocumentExistsError(); } @@ -202,7 +205,7 @@ export abstract class Document { * await user.remove(); * ``` */ - async remove(options = {}) { + async remove(options: removeOptions = {}) { const data = extractDataFromModel(this); const metadata = this.$; const { keyGenerator, scopeName, collectionName, ottoman, keyGeneratorDelimiter } = metadata; diff --git a/src/model/index/refdoc/build-index-refdoc.ts b/src/model/index/refdoc/build-index-refdoc.ts index 7e3505b38..1c7eee0f0 100644 --- a/src/model/index/refdoc/build-index-refdoc.ts +++ b/src/model/index/refdoc/build-index-refdoc.ts @@ -1,15 +1,25 @@ import { indexFieldsName } from '../helpers/index-field-names'; import { ModelMetadata } from '../../interfaces/model-metadata.interface'; +import { TransactionAttemptContext } from 'couchbase'; export const buildViewRefdoc = (metadata: ModelMetadata, Model, fields, prefix) => - async (values, options = {}) => { + async (values, options: { transactionContext?: TransactionAttemptContext } = {}) => { values = Array.isArray(values) ? values : [values]; const key = buildRefKey(fields, values, prefix); const { collection } = metadata; - const result = await collection().get(key, options); - if (result && result.value) { - return Model.findById(result.value); + const c = collection(); + if (options.transactionContext) { + const { transactionContext } = options; + const result = await transactionContext.get(c, key); + if (result?.content) { + return Model.findById(result.content, { transactionContext: transactionContext }); + } + } else { + const result = await c.get(key, options); + if (result?.value) { + return Model.findById(result.value); + } } }; diff --git a/src/model/interfaces/find.interface.ts b/src/model/interfaces/find.interface.ts index 6e52285eb..b9d0b0267 100644 --- a/src/model/interfaces/find.interface.ts +++ b/src/model/interfaces/find.interface.ts @@ -1,5 +1,6 @@ import { IFindOptions } from '../../handler'; import { MutationFunctionOptions } from '../../utils/cast-strategy'; +import { TransactionAttemptContext } from '../../couchbase'; /** * Find One and Update Option parameter. @@ -21,4 +22,5 @@ export interface FindOneAndUpdateOption extends IFindOptions, MutationFunctionOp * set to 'throw': will throw an exception. */ enforceRefCheck?: boolean | 'throw'; + transactionContext?: TransactionAttemptContext; } diff --git a/src/model/model.ts b/src/model/model.ts index 8267b83bc..9674d3795 100644 --- a/src/model/model.ts +++ b/src/model/model.ts @@ -4,7 +4,7 @@ import { IConditionExpr, LogicalWhereExpr, Query } from '../query'; import { UpdateManyOptions } from './interfaces/update-many.interface'; import { FindOneAndUpdateOption } from './interfaces/find.interface'; import { CastOptions, MutationFunctionOptions } from '../utils/cast-strategy'; -import { ModelTypes, saveOptions } from './model.types'; +import { CountOptions as removeOptions, CountOptions, ModelTypes, saveOptions } from './model.types'; export class Model extends Document {} /** @@ -67,7 +67,7 @@ export interface IModel { * User.count({ name: { $like: "%Jane%" } }) * ``` */ - count(filter?: LogicalWhereExpr): Promise; + count(filter?: LogicalWhereExpr, options?: CountOptions): Promise; /** * Retrieves a document by id. @@ -119,7 +119,10 @@ export interface IModel { * const user = await User.createMany([{ name: "John Doe" }, { name: "Jane Doe" }]); * ``` */ - createMany(docs: Doc[] | Doc): Promise>>; + createMany( + docs: Doc[] | Doc, + options?: saveOptions, + ): Promise>>; /** * Updates a document. @@ -242,7 +245,7 @@ export interface IModel { * const result = await User.removeById('userId'); * ``` */ - removeById(id: string): Promise<{ cas: any }>; + removeById(id: string, options?: removeOptions): Promise<{ cas: any }>; /** * Creates a [document](/docs/api/classes/document) from the given data. @@ -509,6 +512,16 @@ export interface IModel { */ namespace: string; + /** + * Return string value with the model collection name + * @example + * ```javascript + * console.log(User.collectionName) + * // "User" + * ``` + */ + collectionName: string; + /** * dropCollection drops a collection from a scope in a bucket. * @param collectionName diff --git a/src/model/model.types.ts b/src/model/model.types.ts index a4acd54a8..8894955c0 100644 --- a/src/model/model.types.ts +++ b/src/model/model.types.ts @@ -1,6 +1,7 @@ import { IModel } from './model'; import { IDocument } from './document'; import { CastOptions } from '../utils/cast-strategy'; +import { TransactionAttemptContext } from 'couchbase'; type WhateverTypes = { [key: string]: any }; @@ -14,6 +15,21 @@ export interface saveOptions { * set to 'throw': will throw an exception. */ enforceRefCheck?: boolean | 'throw'; + transactionContext?: TransactionAttemptContext; +} + +/** + * Represents the options for counting records. + */ +export interface CountOptions { + transactionContext?: TransactionAttemptContext; +} + +/** + * Interface for the removeOptions class. + */ +export interface removeOptions { + transactionContext?: TransactionAttemptContext; } export type ModelTypes = WhateverTypes & diff --git a/src/model/populate.types.ts b/src/model/populate.types.ts index 9ebfd383f..48f7a7043 100644 --- a/src/model/populate.types.ts +++ b/src/model/populate.types.ts @@ -1,6 +1,13 @@ +import { TransactionAttemptContext } from 'couchbase'; + export type FieldsBaseType = string | string[]; export type PopulateSelectBaseType = { select?: FieldsBaseType; populate?: PopulateFieldsType }; export type PopulateSelectType = { [K: string]: FieldsBaseType | PopulateSelectBaseType }; export type PopulateFieldsType = FieldsBaseType | PopulateSelectType; -export type PopulateOptionsType = { deep?: number; lean?: boolean; enforceRefCheck?: boolean | 'throw' }; +export type PopulateOptionsType = { + deep?: number; + lean?: boolean; + enforceRefCheck?: boolean | 'throw'; + transactionContext?: TransactionAttemptContext; +}; diff --git a/src/model/utils/model.utils.ts b/src/model/utils/model.utils.ts index 361dd97ff..00c1da980 100644 --- a/src/model/utils/model.utils.ts +++ b/src/model/utils/model.utils.ts @@ -38,6 +38,7 @@ export const getPopulated = async (options: PopulateAuxOptionsType): Promise[] = []; const l = ref.length; diff --git a/src/model/utils/remove-life-cycle.ts b/src/model/utils/remove-life-cycle.ts index 7d797428c..5935b7933 100644 --- a/src/model/utils/remove-life-cycle.ts +++ b/src/model/utils/remove-life-cycle.ts @@ -16,8 +16,7 @@ export const removeLifeCycle = async ({ id, options, metadata, refKeys, data }) const result = await remove(id, _collection, options); // After store document update index refdocs - refKeys.add = []; - await updateRefdocIndexes(refKeys, null, _collection); + await updateRefdocIndexes(refKeys, null, _collection, options.transactionContext); await execHooks(schema, 'preHooks', HOOKS.REMOVE, { document, result }); diff --git a/src/model/utils/store-life-cycle.ts b/src/model/utils/store-life-cycle.ts index e0becebd4..6517d52a7 100644 --- a/src/model/utils/store-life-cycle.ts +++ b/src/model/utils/store-life-cycle.ts @@ -34,7 +34,7 @@ export const storeLifeCycle = async ({ key, id, data, options, metadata, refKeys if (fieldType instanceof ReferenceType) { const RefModel = ottoman.getModel(fieldType.refModel); try { - await RefModel.findById(document[fieldType.name]); + await RefModel.findById(document[fieldType.name], { transactionContext: options.transactionContext }); } catch (e) { if (e instanceof DocumentNotFoundError) { switch (enforceRefCheck) { @@ -65,7 +65,7 @@ export const storeLifeCycle = async ({ key, id, data, options, metadata, refKeys const result = await store(key, document, options, _colleciton); // After storing the document update the index refdocs - await updateRefdocIndexes(refKeys, id, _colleciton); + await updateRefdocIndexes(refKeys, id, _colleciton, options.transactionContext); if (options.cas) { await execHooks(schema, 'postHooks', HOOKS.UPDATE, document); diff --git a/src/model/utils/update-refdoc-indexes.ts b/src/model/utils/update-refdoc-indexes.ts index 3c19a0e95..364301379 100644 --- a/src/model/utils/update-refdoc-indexes.ts +++ b/src/model/utils/update-refdoc-indexes.ts @@ -1,27 +1,36 @@ import { isDebugMode } from '../../utils/is-debug-mode'; +import { Collection, TransactionAttemptContext } from 'couchbase'; +import { TransactionGetResult, MutationResult } from 'couchbase'; export const updateRefdocIndexes = async ( refKeys: { add: string[]; remove: string[] }, key: string | null, - collection, + collection: Collection, + transactionContext?: TransactionAttemptContext, ) => { - for await (const ref of addRefKeys(refKeys.add, collection, key)) { + for await (const ref of addRefKeys(refKeys.add, collection, key, transactionContext)) { if (isDebugMode()) { console.log('Adding refdoc index:', ref); } } - for await (const ref of removeRefKeys(refKeys.remove, collection)) { + for await (const ref of removeRefKeys(refKeys.remove, collection, transactionContext)) { if (isDebugMode()) { console.log('Removing refdoc index:', ref); } } }; -async function* addRefKeys(refKeys, collection, key) { +async function* addRefKeys(refKeys, collection, key, transactionContext?: TransactionAttemptContext) { for (const ref of refKeys) { if (ref.length < 250) { - yield collection.insert(ref, key).catch((e) => { + let promise: Promise; + if (transactionContext) { + promise = transactionContext.insert(collection, ref, key); + } else { + promise = collection.insert(ref, key); + } + yield promise.catch((e) => { if (isDebugMode()) { console.warn(e); } @@ -33,9 +42,16 @@ async function* addRefKeys(refKeys, collection, key) { } } -async function* removeRefKeys(refKeys, collection) { +async function* removeRefKeys(refKeys, collection, transactionContext?: TransactionAttemptContext) { for (const ref of refKeys) { - yield collection.remove(ref).catch((e) => { + let promise: Promise; + if (transactionContext) { + const doc = await transactionContext.get(collection, ref); + promise = transactionContext.remove(doc); + } else { + promise = collection.remove(ref); + } + yield promise.catch((e) => { if (isDebugMode()) { console.warn(e); } diff --git a/src/ottoman/ottoman.ts b/src/ottoman/ottoman.ts index 100df0527..2a591153e 100644 --- a/src/ottoman/ottoman.ts +++ b/src/ottoman/ottoman.ts @@ -36,6 +36,8 @@ import { Collection, NodeCallback, CouchbaseError, + TransactionAttemptContext, + TransactionOptions, } from 'couchbase'; import { generateUUID } from '../utils/generate-uuid'; import { SearchQuery } from 'couchbase/dist/searchquery'; @@ -412,7 +414,10 @@ export class Ottoman { * WHERE (address LIKE '%57-59%' OR free_breakfast = true) * ``` */ - async query(query: string, options: QueryOptions = {}) { + async query(query: string, options: QueryOptions & { transactionContext?: TransactionAttemptContext } = {}) { + if (options.transactionContext) { + return options.transactionContext.query(query, options); + } return this.cluster.query(query, options); } @@ -491,6 +496,36 @@ export class Ottoman { await this.ensureCollections(); await this.ensureIndexes(options); } + + /** + * The `$transactions` function passes a `TransactionAttemptContext` object to the transaction function. + * @param transactionFn The function to be executed as a transaction. + * + * @param config + * - `durabilityLevel`: Specifies the level of synchronous durability level. + * - `timeout`: Specifies the timeout for the transaction. + * + * @example + * ```javascript + * await otttoman.$transactions(async (ctx: TransactionAttemptContext) => { + * const user = await User.create({ name: "John Doe" }, { transactionContext: ctx }); + * }); + * ``` + * + * Notice: You **MUST pass the `transactionContext` option in the model methods** to execute the operation(s) as a transaction. + */ + async $transactions( + transactionFn: (attempt: TransactionAttemptContext) => Promise, + config?: TransactionOptions, + ) { + if (Object.keys(this.refdocIndexes).length > 0) { + console.warn( + '\x1b[4m\x1b[43mWARNING:\x1b[0m One or more RefDoc indexes have been detected in this project. RefDoc indexes are not currently supported in transactions. \nFor more information visit https://ottomanjs.com/docs/advanced/transactions#transactions-with-refdoc-indexes.', + ); + } + + await this.cluster.transactions().run(transactionFn, config); + } } const delay = (timems) => diff --git a/src/utils/cast-strategy.ts b/src/utils/cast-strategy.ts index 2c6aa0dfe..e8a168269 100644 --- a/src/utils/cast-strategy.ts +++ b/src/utils/cast-strategy.ts @@ -1,4 +1,5 @@ import { applyDefaultValue, CoreType, IOttomanType, Schema, ValidationError } from '../schema'; +import { TransactionAttemptContext } from 'couchbase'; /** * Cast Strategies @@ -51,6 +52,7 @@ export type MutationFunctionOptions = { strict?: ApplyStrategy; maxExpiry?: number; enforceRefCheck?: boolean | 'throw'; + transactionContext?: TransactionAttemptContext; }; export const cast = (