UNPKG

@sap/cds

Version:

SAP Cloud Application Programming Model - CDS for Node.js

205 lines (173 loc) 6.91 kB
const cds = require('../index'), { srv_tx_compat_for_afc = true } = cds.env.features const EventContext = require('../req/context') class RootContext extends EventContext { static for(_) { return _ instanceof EventContext ? _ : super.for(_,'as root') } } class NestedContext extends EventContext { static for(_) { return _ instanceof EventContext ? _ : super.for(_) } } /** * This is the implementation of the `srv.tx(req)` method. It constructs * a new Transaction as a derivate of the `srv` (i.e. {__proto__:srv}) * @typedef {import('./cds.Service')} Service * @this {Service} @param { EventContext } ctx * @returns { Promise<Transaction & Service> } */ module.exports = exports = function srv_tx (ctx,fn) { const srv = this // srv.tx().tx() -> idempotent no-op (questionable: ignoring all futher arguments) if (srv.context) return srv // Usage variant 1: srv.tx() -> get root tx for a service if (!ctx) return RootTransaction.for (srv) // Usage variant 2: srv.tx (fn) -> run fn in a new root tx if (typeof ctx === 'function') [ ctx, fn ] = [ undefined, ctx ] if (typeof fn === 'function') { const tx = RootTransaction.for (srv, ctx) return cds._with (tx, ()=> Promise.resolve(fn(tx)) .then (tx.commit, tx.rollback)) } // Usage variant 3: srv.tx (req) -> LEGACY way of propagating tx before cds.context if (ctx instanceof EventContext) { if (ctx.tx) return NestedTransaction.for (srv, ctx) else return RootTransaction.for (srv, ctx) } // Usage variant 4: srv.tx ({...}) -> get a new root tx for a service if (srv_tx_compat_for_afc) { // REVISIT: compatibility for AFC only -> we should get rid of that if (ctx._txed_before) return NestedTransaction.for (srv, ctx._txed_before) else Object.defineProperty (ctx, '_txed_before', { value: ctx = RootContext.for(ctx) }) } return RootTransaction.for (srv, ctx) } class Transaction { /** * Returns an already started tx for given srv, or creates a new instance */ static for (srv, ctx) { const txs = ctx.context.transactions || ctx.context._set ('transactions', new Map) let tx = txs.get (srv) if (!tx) txs.set (srv, tx = new this (srv,ctx)) return tx } /** @param {Service} srv */ constructor (srv, ctx) { const tx = { __proto__:srv, _kind: new.target.name, context: ctx } const proto = new.target.prototype tx.commit = proto.commit.bind(tx) tx.rollback = proto.rollback.bind(tx) if (srv.isExtensible) { const m = cds.context?.model if (m) tx.model = m } return _init(tx) } /** * In addition to srv.commit, sets the transaction to committed state, * in order to prevent continuous use without explicit reopen (i.e., begin). */ async commit (res) { if (this.ready) { //> nothing to do if no transaction started at all if (this.__proto__.commit) await this.__proto__.commit.call (this,res) _init(this).ready = 'committed' } return res } /** * In addition to srv.rollback, sets the transaction to rolled back state, * in order to prevent continuous use without explicit reopen (i.e., begin). */ async rollback (err) { // nothing to do if transaction already rolled back if (this.ready === 'rolled back') return /* * srv.on('error', function (err, req) { ... }) * synchronous modification of passed error only * err is undefined if nested tx (cf. "root.before ('failed', ()=> this.rollback())") */ // FIXME: with noa, this.context === cds.context and not the individual cds.Request if (err && this.handlers?._error) for (const each of this.handlers._error) each.handler.call(this, err, this.context) if (this.ready) { //> nothing to do if no transaction started at all // don't actually roll back if already committed (e.g., error thrown in on succeeded or on done) if (this.ready !== 'committed' && this.__proto__.rollback) await this.__proto__.rollback.call (this,err) _init(this).ready = 'rolled back' } if (err) throw err } } class RootTransaction extends Transaction { /** * Register the new transaction with the given context. * @param {EventContext} ctx */ static for (srv, ctx) { ctx = RootContext.for (ctx?.tx?._done ? {} : ctx) return ctx.tx = super.for (srv, ctx) } /** * In addition to srv.commit, ensures all nested transactions * are informed by emitting 'succeeded' event to them all. */ async commit (res) { try { await this.context.emit ('commit',res) //> allow custom handlers req.before('commit') await super.commit (res) this._done = 'committed' await this.context.emit ('succeeded',res) await this.context.emit ('done') } catch (err) { await this.rollback (err) } return res } /** * In addition to srv.rollback, ensures all nested transactions * are informed by emitting 'failed' event to them all. */ async rollback (err) { // nothing to do if transaction already rolled back (we need to check here as well to not emit failed twice) if (this.ready === 'rolled back') return this._done = 'rolled back' try { await this.context.emit ('failed',err) await super.rollback (err) } finally { await this.context.emit ('done') } if (err) throw err } } class NestedTransaction extends Transaction { static for (srv,ctx) { ctx = NestedContext.for (ctx) return super.for (srv, ctx) } /** * Registers event listeners with the given context, to commit or rollback * when the root tx is about to commit or rollback. * @param {EventContext} ctx */ constructor (srv,ctx) { super (srv,ctx) ctx.before ('succeeded', ()=> this.commit()) ctx.before ('failed', ()=> this.rollback()) if ('end' in srv) ctx.once ('done', ()=> srv.end()) } } /** * Ensure the service's implementation of .begin is called appropriately * before any .dispatch. */ const _init = (tx) => { if ('begin' in tx) tx.dispatch = _begin else tx.ready = true //> to allow subclasses w/o .begin return tx } const _begin = async function (req) { if (!req.query && req.method === 'BEGIN') // IMPORTANT: !req.query is to exclude batch requests return this.ready = this.__proto__.dispatch.call (this,req) // Protection against unintended tx.run() after root tx.commit/rollback() if (this.ready === 'rolled back') throw exports._is_done (this.ready) if (this.ready === 'committed') throw exports._is_done (this.ready) if (!this.ready && this.context.tx._done) throw exports._is_done (this.context.tx._done) if (!this.ready) this.ready = this.begin().then(()=>true) await this.ready delete this.dispatch return this.dispatch (req) } exports._is_done = done => new cds.error ( `Transaction is ${done}, no subsequent .run allowed, without prior .begin`, { code: 'TRANSACTION_CLOSED' } )