transactional
Version:
Reactive objects with transactional updates and automatic serialization
456 lines (354 loc) • 14 kB
text/typescript
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 );
}
}