Skip to content

Commit

Permalink
feat: CommonDBTransactionOptions, forbidTransactionReadAfterWrite
Browse files Browse the repository at this point in the history
  • Loading branch information
kirillgroshkov committed Jan 18, 2024
1 parent de67d7e commit 2ec161a
Show file tree
Hide file tree
Showing 5 changed files with 63 additions and 6 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
- { uses: actions/setup-node@v4, with: { node-version: 'lts/*', cache: 'yarn' } }

# Cache for npm/npx in ~/.npm
- uses: actions/cache@v3
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-v1-${{ runner.os }}
Expand Down
50 changes: 47 additions & 3 deletions src/adapter/inmemory/inMemory.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import {
CommonDB,
commonDBFullSupport,
CommonDBTransactionOptions,
CommonDBType,
DBIncrement,
DBOperation,
Expand All @@ -58,6 +59,17 @@ export interface InMemoryDBCfg {
*/
tablesPrefix: string

/**
* Many DB implementations (e.g Datastore and Firestore) forbid doing
* read operations after a write/delete operation was done inside a Transaction.
*
* To help spot that type of bug - InMemoryDB by default has this setting to `true`,
* which will throw on such occasions.
*
* Defaults to true.
*/
forbidTransactionReadAfterWrite?: boolean

/**
* @default false
*
Expand Down Expand Up @@ -96,6 +108,7 @@ export class InMemoryDB implements CommonDB {
this.cfg = {
// defaults
tablesPrefix: '',
forbidTransactionReadAfterWrite: true,
persistenceEnabled: false,
persistZip: true,
persistentStoragePath: './tmp/inmemorydb',
Expand Down Expand Up @@ -273,8 +286,12 @@ export class InMemoryDB implements CommonDB {
return Readable.from(queryInMemory(q, Object.values(this.data[table] || {}) as ROW[]))
}

async runInTransaction(fn: DBTransactionFn): Promise<void> {
const tx = new InMemoryDBTransaction(this)
async runInTransaction(fn: DBTransactionFn, opt: CommonDBTransactionOptions = {}): Promise<void> {
const tx = new InMemoryDBTransaction(this, {
readOnly: false,
...opt,
})

try {
await fn(tx)
await tx.commit()
Expand Down Expand Up @@ -361,15 +378,28 @@ export class InMemoryDB implements CommonDB {
}

export class InMemoryDBTransaction implements DBTransaction {
constructor(private db: InMemoryDB) {}
constructor(
private db: InMemoryDB,
private opt: Required<CommonDBTransactionOptions>,
) {}

ops: DBOperation[] = []

// used to enforce forbidReadAfterWrite setting
writeOperationHappened = false

async getByIds<ROW extends ObjectWithId>(
table: string,
ids: string[],
opt?: CommonDBOptions,
): Promise<ROW[]> {
if (this.db.cfg.forbidTransactionReadAfterWrite) {
_assert(
!this.writeOperationHappened,
`InMemoryDBTransaction: read operation attempted after write operation`,
)
}

return await this.db.getByIds(table, ids, opt)
}

Expand All @@ -378,6 +408,13 @@ export class InMemoryDBTransaction implements DBTransaction {
rows: ROW[],
opt?: CommonDBSaveOptions<ROW>,
): Promise<void> {
_assert(
!this.opt.readOnly,
`InMemoryDBTransaction: saveBatch(${table}) called in readOnly mode`,
)

this.writeOperationHappened = true

this.ops.push({
type: 'saveBatch',
table,
Expand All @@ -387,6 +424,13 @@ export class InMemoryDBTransaction implements DBTransaction {
}

async deleteByIds(table: string, ids: string[], opt?: CommonDBOptions): Promise<number> {
_assert(
!this.opt.readOnly,
`InMemoryDBTransaction: deleteByIds(${table}) called in readOnly mode`,
)

this.writeOperationHappened = true

this.ops.push({
type: 'deleteByIds',
table,
Expand Down
3 changes: 2 additions & 1 deletion src/base.common.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CommonDB, CommonDBSupport, CommonDBType } from './common.db'
import {
CommonDBOptions,
CommonDBSaveOptions,
CommonDBTransactionOptions,
DBPatch,
DBTransactionFn,
RunQueryResult,
Expand Down Expand Up @@ -83,7 +84,7 @@ export class BaseCommonDB implements CommonDB {
throw new Error('deleteByIds is not implemented')
}

async runInTransaction(fn: DBTransactionFn): Promise<void> {
async runInTransaction(fn: DBTransactionFn, opt?: CommonDBTransactionOptions): Promise<void> {
const tx = new FakeDBTransaction(this)
await fn(tx)
// there's no try/catch and rollback, as there's nothing to rollback
Expand Down
6 changes: 5 additions & 1 deletion src/common.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
CommonDBOptions,
CommonDBSaveOptions,
CommonDBStreamOptions,
CommonDBTransactionOptions,
DBPatch,
DBTransactionFn,
RunQueryResult,
Expand Down Expand Up @@ -163,8 +164,11 @@ export interface CommonDB {
* Transaction is automatically committed if fn resolves normally.
* Transaction is rolled back if fn throws, the error is re-thrown in that case.
* Graceful rollback is allowed on tx.rollback()
*
* By default, transaction is read-write,
* unless specified as readOnly in CommonDBTransactionOptions.
*/
runInTransaction: (fn: DBTransactionFn) => Promise<void>
runInTransaction: (fn: DBTransactionFn, opt?: CommonDBTransactionOptions) => Promise<void>
}

/**
Expand Down
8 changes: 8 additions & 0 deletions src/db.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ export interface DBTransaction {
rollback: () => Promise<void>
}

export interface CommonDBTransactionOptions {
/**
* Default is false.
* If set to true - Transaction is created as read-only.
*/
readOnly?: boolean
}

export interface CommonDBOptions {
/**
* If passed - the operation will be performed in the context of that DBTransaction.
Expand Down

0 comments on commit 2ec161a

Please sign in to comment.