UNPKG

transactional

Version:

Reactive objects with transactional updates and automatic serialization

456 lines (354 loc) 14 kB
import { log, assign, defaults, omit } from '../tools.ts' import { Class } from '../class.ts' import { CollectionConstructor, ICollectionDefinition, RecordDefinition } from '../types.ts' import { compile } from './define.ts' /** * Everything related to record's transactional updates */ export interface IUpdatePipeline{ canBeUpdated( prev : any, next : any ) : boolean transform( value : any, options : Options, prev : any, model : TransactionalRecord ) : any isChanged( a : any, b : any ) : boolean handleChange( next : any, prev : any, model : TransactionalRecord ) : void clone( value : any ) : any toJSON? : ( value : any, key : string ) => any } interface Options { silent? : boolean parse? : boolean clone? : boolean } interface IAttributes { [ key : string ] : any } interface IAttrSpecs { [ key : string ] : IUpdatePipeline } interface IOwned { _owner : IOwner _ownerKey? : string getOwner() : IOwner } interface IOwner extends IOwned { _onChildrenChange( child : IOwned, options : Options ) : void } // Client unique id counter let _cidCounter : number = 0; export class TransactionalRecord extends Class implements IOwner { static Collection : CollectionConstructor static define( protoProps : RecordDefinition, staticProps ){ const baseProto : TransactionalRecord = Object.getPrototypeOf( this.prototype ), BaseConstructor = < typeof TransactionalRecord >baseProto.constructor; if( protoProps ) { // Compile attributes spec, creating definition mixin. const definition = compile( protoProps.attributes, baseProto._attributes ); // Explicit 'properties' declaration overrides auto-generated attribute properties. assign( definition.properties, protoProps.properties || {} ); // Merge in definition. defaults( definition, omit( protoProps, 'attributes', 'collection' ) ); super.define( definition ); } this.Collection = this._defineCollection( protoProps && protoProps.collection, BaseConstructor.Collection ); return this; } // Obtain properly defined Collection constructor protected static _defineCollection( collection : CollectionConstructor | ICollectionDefinition | void, BaseCollection : CollectionConstructor ) : CollectionConstructor { let CollectionConstructor : CollectionConstructor; // If collection constructor is specified, just take it. if( typeof collection === 'function' ) { CollectionConstructor = <CollectionConstructor> collection; } // Same when Collection is specified as static class member. else if( this.Collection !== BaseCollection ){ CollectionConstructor = this.Collection; } // Otherwise we need to create new Collection type... else{ // ...which must extend COllection of our base Record. CollectionConstructor = class Collection extends BaseCollection {}; CollectionConstructor.define( <ICollectionDefinition> collection ); } // Link collection with the record CollectionConstructor.prototype.Record = this; return CollectionConstructor; } /*********************************** * Core Members */ // Previous attributes _previousAttributes : {} // Current attributes attributes : IAttributes // Transactional control _changing : boolean _pending : boolean /** * Ownerhsip API */ // Reference to owner _owner : IOwner // Owner's attribute name, if it's Record _ownerKey : string; // Returns owner without the key (usually it's collection) get collection() : IOwner { return this._ownerKey ? null : this._owner; } // Returns Record owner skipping collections. getOwner() : IOwner { const { _owner } = this; return this._ownerKey ? _owner : ( _owner && _owner._owner ); } /*********************************** * Notification API */ // Record is changed _notifyChange( options : Options ) : void {} // Record's attribute is changed _notifyChangeAttr( key : string, options : Options ) : void {} /*********************************** * Identity managements */ // Client unique id cid : string; // Client id prefix cidPrefix : string; // Id attribute name ('id' by default) idAttribute : string; // Fixed 'id' property pointing to id attribute get id() : string | number { return this.attributes[ this.idAttribute ]; } set id( x : string | number ){ setAttribute( this, this.idAttribute, x ); } /*********************************** * Dynamically compiled stuff */ // Attributes specifications _attributes : IAttrSpecs // Attributes object copy constructor Attributes : new ( attrs : {} ) => IAttributes // Optimized forEach function for traversing through attributes, with pretective default implementation forEachAttr( attrs : {}, iteratee : ( value : any, key? : string, spec? : IUpdatePipeline ) => void ){ const { _attributes } = this; for( let name in attrs ){ const spec = _attributes[ name ]; if( spec ){ iteratee( attrs[ name ], name, spec ); } else{ log.warn( '[Unknown Attribute]', this, 'Unknown record attribute "' + name + '" is ignored:', attrs ); } } } // Attributes-level serialization _toJSON(){ return {}; } // Attributes-level parse _parse( data ){ return data; } // Create record default values, optionally augmenting given values defaults( values? : {} ){ return {}; } /*************************************************** * Record construction */ // Create record, optionally setting owner constructor( a_values? : {}, a_options? : Options, owner? : IOwner ){ super(); const options = a_options || {}, values = ( options.parse ? this.parse( a_values ) : a_values ) || {}; this._changing = this._pending = false; this._owner = owner; this.cid = this.cidPrefix + _cidCounter++; // TODO: type error for wrong object. const attributes = options.clone ? cloneAttributes( this, values ) : this.defaults( values ); this.forEachAttr( attributes, ( value : any, key : string, attr : IUpdatePipeline ) => { const next = attributes[ key ] = attr.transform( value, options, void 0, this ); attr.handleChange( next, void 0, this ); }); this.attributes = this._previousAttributes = attributes; this.initialize( a_values, a_options ); } // Initialization callback, to be overriden by the subclasses initialize( values?, options? ){} // Deeply clone record, optionally setting new owner. clone( owner? : any ) : TransactionalRecord { return new (<any>this.constructor)( this.attributes, { clone : true }, owner ); } /** * Serialization control */ // Default record-level serializer, to be overriden by subclasses toJSON(){ const json = {}; this.forEachAttr( this.attributes, ( value, key, { toJSON } ) =>{ if( toJSON ){ json[ key ] = toJSON.call( this, value, key ); } }); } // Default record-level parser, to be overriden by the subclasses parse( data ){ return this._parse( data ); } /** * Transactional control */ // Object sync API set( values : {}, options? : Options ) : this { if( values ){ this.createTransaction( values, options ).commit( options ); } return this; } // Create transaction createTransaction( a_values : {}, options : Options = {} ) : Transaction { const transaction = new Transaction( this ), { changes, nested } = transaction, { attributes } = this, values = options.parse ? this.parse( a_values ) : a_values; if( Object.getPrototypeOf( values ) === Object.prototype ){ this.forEachAttr( values, ( value, key : string, attr : IUpdatePipeline ) => { const prev = attributes[ key ]; // handle deep update... if( attr.canBeUpdated( prev, value ) ) { nested.push( prev.createTransaction( value, options ) ); return; } // cast and hook... const next = attr.transform( value, options, prev, this ); if( attr.isChanged( next, prev ) ) { attributes[ key ] = next; changes.push( key ); // Do the rest of the job after assignment attr.handleChange( next, prev, this ); } } ); } else{ log.error( '[Type Error]', this, 'Record update rejected (', values, '). Incompatible type.' ); } return transaction; } // Execute given function in the scope of ad-hoc transaction transaction( fun : ( self : this ) => void, options : Options = {} ) { const isRoot = begin( this ); fun( this ); isRoot && commit( this, options ); } // Handle nested changes _onChildrenChange( child : IOwned, options : Options ) : void { this.forceAttributeChange( child._ownerKey, options ); } forceAttributeChange( key, options : Options = {} ){ // Touch an attribute in bounds of transaction const isRoot = begin( this ); if( !options.silent ){ this._pending = true; key && this._notifyChangeAttr( key, options ); } isRoot && commit( this, options ); } }; /************************************************** * Initialize Record prototype elements */ const recordProto = TransactionalRecord.prototype; // Default client id prefix recordProto.cid = 'c'; // Default id attribute name recordProto.idAttribute = 'id'; /*********************************************** * Helper functions */ // Deeply clone record attributes function cloneAttributes( model : TransactionalRecord, a_attributes : IAttributes ) : IAttributes { const attributes = new model.Attributes( a_attributes ); model.forEachAttr( attributes, function( value, name, attr ){ attributes[ name ] = attr.clone( value ); //TODO: Add owner? } ); return attributes; } // Optimized single attribute transactional update. To be called from attributes setters export function setAttribute( model : TransactionalRecord, name : string, value : any ) : void { const isRoot = begin( model ), options = {}; const { attributes } = model, spec = model._attributes[ name ], prev = attributes[ name ]; // handle deep update... if( spec.canBeUpdated( prev, value ) ) { prev.createTransaction( value, options ).commit( options ); } else { // cast and hook... const next = spec.transform( value, options, prev, model ); if( spec.isChanged( next, prev ) ) { attributes[ name ] = next; // Do the rest of the job after assignment if( spec.handleChange ) { spec.handleChange( next, prev, this ); } model._pending = true; model._notifyChangeAttr( name, options ); } } isRoot && commit( model, options ); } /** * Transactional brackets * begin( model ) => true | false; * commit( model, options ) => void 0 */ // Start transaction on the record. Return true if it's opening transaction. function begin( model : TransactionalRecord ) : boolean { const isRoot = !model._changing; if( isRoot ){ // If it's opening transaction, copy attributes model._changing = true; model._previousAttributes = new model.Attributes( model.attributes ); } return isRoot; } // Commit transaction. Send out change event and notify owner. function commit( model : TransactionalRecord, options : Options ){ if( !options.silent ){ while( model._pending ){ model._pending = false; model._notifyChange( options ); } } model._pending = false; model._changing = false; // TODO: should it be in the transaction scope? // So, upper-level change:attr handlers will work in the scope of current // transaction. Short answer: no. Leave it like this. const { _owner } = model; if( _owner ){ _owner._onChildrenChange( model, options ); } } // Transaction class. Implements two-phase transactions on object's tree. class Transaction { isRoot : boolean changes : string[] nested : Transaction[] // open transaction constructor( public model : TransactionalRecord ){ this.isRoot = begin( model ); this.model = model; this.changes = []; this.nested = []; } // commit transaction commit( options : Options = {} ){ const { nested, model } = this; // Commit all nested transactions... for( let i = 0; i < nested.length; i++ ){ nested[ i ].commit( options ); } // Notify listeners on attribute changes... if( !options.silent ){ const { changes } = this; if( changes.length ){ model._pending = true; } for( let i = 0; i < changes.length; i++ ){ model._notifyChangeAttr( changes[ i ], options ) } } this.isRoot && commit( model, options ); } }