@type-r/models
Version:
The serializable type system for JS and TypeScript
462 lines (362 loc) • 15.7 kB
text/typescript
import { abortIO, IOEndpoint, IONode, IOPromise } from './io-tools';
import { EventCallbacks, define, definitions, eventsApi, Logger, LogLevel, Messenger, MessengerDefinition, MessengersByCid, mixinRules, mixins, MixinsState, throwingLogger } from '@type-r/mixture';
import { resolveReference, Traversable } from './traversable';
import { ChildrenErrors, Validatable, ValidationError } from './validation';
import { Linked } from '@linked/value/lib';
const { trigger3, on, off } = eventsApi;
/***
* Abstract class implementing ownership tree, tho-phase transactions, and validation.
* 1. createTransaction() - apply changes to an object tree, and if there are some events to send, transaction object is created.
* 2. transaction.commit() - send and process all change events, and close transaction.
*/
/** @private */
export interface TransactionalDefinition extends MessengerDefinition {
endpoint? : IOEndpoint
}
export enum ItemsBehavior {
share = 0b0001,
listen = 0b0010,
persistent = 0b0100
}
// Transactional object interface
export interface Transactional extends Messenger {}
export abstract class Transactional implements Messenger, IONode, Validatable, Traversable {
// Mixins are hard in TypeScript. We need to copy type signatures over...
// Here goes 'Mixable' mixin.
static endpoint : IOEndpoint;
/** @internal */
static __super__ : object;
static mixins : MixinsState;
static define : ( definition? : TransactionalDefinition, statics? : object ) => typeof Transactional;
static extend : <T extends TransactionalDefinition>( definition? : T, statics? : object ) => any;
static onDefine( definitions : TransactionalDefinition, BaseClass : typeof Transactional ){
if( definitions.endpoint ) this.prototype._endpoint = definitions.endpoint;
Messenger.onDefine.call( this, definitions, BaseClass );
};
static onExtend( BaseClass : typeof Transactional ) : void {
// Make sure we don't inherit class factories.
if( BaseClass.create === this.create ) {
this.create = Transactional.create;
}
}
// Define extendable mixin static properties.
static create<M extends new ( ...args ) => any>( this : M, a? : any, b? : any ) : InstanceType<M> {
return new (this as any)( a, b );
}
// State accessor.
/** @internal */
readonly __inner_state__ : any;
// Shared modifier (used by collections of shared models)
/** @internal */
_shared? : number;
dispose() : void {
if( this._disposed ) return;
abortIO( this );
this._owner = void 0;
this._ownerKey = void 0;
this.off();
this.stopListening();
this._disposed = true;
}
cidPrefix : string
// Unique version token replaced on change
/** @internal */
_changeToken : {} = {}
// true while inside of the transaction
/** @internal */
_transaction : boolean = false;
// Holds current transaction's options, when in the middle of transaction and there're changes but is an unsent change event
/** @internal */
_isDirty : TransactionOptions = null;
// Backreference set by owner (Model, Collection, or other object)
/** @internal */
_owner : Owner = void 0;
// Key supplied by owner. Used by record to identify attribute key.
// Only collections doesn't set the key, which is used to distinguish collections.
/** @internal */
_ownerKey : string = void 0;
// Name of the change event
/** @internal */
_changeEventName : string
/**
* Subsribe for the changes.
*/
onChanges( handler : Function, target? : Messenger ){
on( this, this._changeEventName, handler, target );
}
/**
* Unsubscribe from changes.
*/
offChanges( handler? : Function, target? : Messenger ){
off( this, this._changeEventName, handler, target );
}
/**
* Listen to changes event.
*/
listenToChanges( target : Transactional, handler ){
this.listenTo( target, target._changeEventName, handler );
}
constructor( cid : string | number ){
this.cid = this.cidPrefix + cid;
}
// Deeply clone ownership subtree
abstract clone( options? : CloneOptions ) : this
// Execute given function in the scope of ad-hoc transaction.
transaction( fun : ( self : this ) => void, options : TransactionOptions = {} ) : void{
const isRoot = transactionApi.begin( this );
const update = fun.call( this, this );
update && this.set( update );
isRoot && transactionApi.commit( this );
}
// Assign transactional object "by value", copying aggregated items.
assignFrom( a_source : Transactional | Object | Linked<Transactional> ) : this {
// Unpack linked value.
const source = a_source instanceof Linked ? a_source.value : a_source;
// Need to delay change events until change token will by synced.
this.transaction( () =>{
this.set( ( source as any).__inner_state__ || source, { merge : true } );
// Synchronize change tokens
const { _changeToken } = source as any;
if( _changeToken ){
this._changeToken = _changeToken;
}
});
return this;
}
// Create object from JSON. Throw if validation fail.
static from<T extends new ( a?, b? ) => Transactional >( this : T, json : any, { strict, ...options } : { strict? : boolean } & TransactionOptions = {} ) : InstanceType<T>{
const obj : Transactional = ( this as any ).create( json, { ...options, logger : strict ? throwingLogger : void 0 } );
if( strict && obj.validationError ){
obj.eachValidationError( ( error, key, obj ) => {
throw new Error( `${ obj.getClassName() }.${ key }: ${ error }` );
});
}
return obj as any;
}
// Apply bulk object update without any notifications, and return open transaction.
// Used internally to implement two-phase commit.
// Returns null if there are no any changes.
/** @internal */
abstract _createTransaction( values : any, options? : TransactionOptions ) : Transaction | void
// Apply bulk in-place object update in scope of ad-hoc transaction
abstract set( values : any, options? : TransactionOptions ) : this;
// Parse function applied when 'parse' option is set for transaction.
parse( data : any, options? : TransactionOptions ) : any { return data }
// Convert object to the serializable JSON structure
abstract toJSON( options? : object ) : {}
/*******************
* Traversals and member access
*/
// Get object member by its key.
abstract get( key : string ) : any
// Get object member by symbolic reference.
deepGet( reference : string ) : any {
return resolveReference( this, reference, ( object, key ) => object.get ? object.get( key ) : object[ key ] );
}
//_isCollection : boolean
// Return owner skipping collections.
getOwner() : Owner {
return this._owner;
}
// Store used when owner chain store lookup failed. Static value in the prototype.
/** @internal */
_defaultStore : Transactional
// Locate the closest store. Store object stops traversal by overriding this method.
getStore() : Transactional {
const { _owner } = this;
return _owner ? <Transactional> _owner.getStore() : this._defaultStore;
}
/***************************************************
* Iteration API
*/
// Loop through the members. Must be efficiently implemented in container class.
/** @internal */
_endpoint : IOEndpoint
/** @internal */
_ioPromise : IOPromise<this>
hasPendingIO() : IOPromise<this> { return this._ioPromise; }
//fetch( options? : object ) : IOPromise<this> { throw new Error( "Not implemented" ); }
getEndpoint() : IOEndpoint {
return getOwnerEndpoint( this ) || this._endpoint;
}
/*********************************
* Validation API
*/
// Lazily evaluated validation error
/** @internal */
_validationError : ValidationError = void 0
// Validate ownership tree and return valudation error
get validationError() : ValidationError {
const error = this._validationError || ( this._validationError = new ValidationError( this ) );
return error.length ? error : null;
}
// Validate nested members. Returns errors count.
/** @internal */
abstract _validateNested( errors : ChildrenErrors ) : number
// Object-level validator. Returns validation error.
validate( obj? : Transactional ) : any {}
// Return validation error (or undefined) for nested object with the given key.
getValidationError( key? : string ) : any {
var error = this.validationError;
return ( key ? error && error.nested[ key ] : error ) || null;
}
// Get validation error for the given symbolic reference.
deepValidationError( reference : string ) : any {
return resolveReference( this, reference, ( object, key ) => object.getValidationError( key ) );
}
// Iterate through all validation errors across the ownership tree.
eachValidationError( iteratee : ( error : any, key : string, object : Transactional ) => void ) : void {
const { validationError } = this;
validationError && validationError.eachError( iteratee, this );
}
// Check whenever member with a given key is valid.
isValid( key? : string ) : boolean {
return !this.getValidationError( key );
}
valueOf() : Object { return this.cid; }
toString(){ return this.cid; }
// Get class name for an object instance. Works fine with ES6 classes definitions (not in IE).
getClassName() : string {
const { name } = <any>this.constructor;
if( name !== 'Subclass' ) return name;
}
// Logging interface for run time errors and warnings.
/** @internal */
abstract _log( level : LogLevel, topic : string, text : string, value : any, logger? : Logger ) : void
}
export interface CloneOptions {
// 'Pin store' shall assign this._defaultStore = this.getStore();
pinStore? : boolean
}
// Owner must accept children update events. It's an only way children communicates with an owner.
/** @private */
export interface Owner extends Traversable, Messenger {
/** @internal */
_onChildrenChange( child : Transactional, options : TransactionOptions ) : void;
getOwner() : Owner
getStore() : Transactional
}
// Transaction object used for two-phase commit protocol.
// Must be implemented by subclasses.
// Transaction must be created if there are actual changes and when markIsDirty returns true.
/** @private */
export interface Transaction {
// Object transaction is being made on.
object : Transactional
// Send out change events, process update triggers, and close transaction.
// Nested transactions must be marked with isNested flag (it suppress owner notification).
commit( initiator? : Transactional )
}
// Options for distributed transaction
export interface TransactionOptions {
// Invoke parsing
parse? : boolean
// Optional logger
logger? : Logger
// Suppress change notifications and update triggers
silent? : boolean
// Update existing transactional members in place, or skip the update (ignored by models)
merge? : boolean // =true
// Should collections remove elements in set (ignored by models)
remove? : boolean // =true
// Always replace enclosed objects with new instances
reset? : boolean // = false
// Do not dispose aggregated members
unset? : boolean
validate? : boolean
// IO method name if the transaction is initiated as a result of IO operation
ioMethod? : 'save' | 'fetch'
// The hint for IOEndpoint
// If `true`, `record.save()` will behave as "upsert" operation for the records having id.
upsert? : boolean
}
/**
* Low-level transactions API. Must be used like this:
* const isRoot = begin( record );
* ...
* isRoot && commit( record, options );
*
* When committing nested transaction, the flag must be set to true.
* commit( object, options, isNested )
*/
export const transactionApi = {
// Start transaction. Return true if it's the root one.
/** @private */
begin( object : Transactional ) : boolean {
return object._transaction ? false : ( object._transaction = true );
},
// Mark object having changes inside of the current transaction.
// Returns true whenever there notifications are required.
/** @private */
markAsDirty( object : Transactional, options : TransactionOptions ) : boolean {
// If silent option is in effect, don't set isDirty flag.
const dirty = !options.silent;
if( dirty ) object._isDirty = options;
// Reset version token.
object._changeToken = {};
// Object is changed, so validation must happen again. Clear the cache.
object._validationError = void 0;
return dirty;
},
// Commit transaction. Send out change event and notify owner. Returns true if there were changes.
// Must be executed for the root transaction only.
/** @private */
commit( object : Transactional, initiator? : Transactional ){
let originalOptions = object._isDirty;
if( originalOptions ){
// Send the sequence of change events, handling chained handlers.
while( object._isDirty ){
const options = object._isDirty;
object._isDirty = null;
trigger3( object, object._changeEventName, object, options, initiator );
}
// Mark transaction as closed.
object._transaction = false;
// Notify owner on changes out of transaction scope.
const { _owner } = object;
if( _owner && _owner !== <any> initiator ){ // If it's the nested transaction, owner is already aware there are some changes.
_owner._onChildrenChange( object, originalOptions );
}
}
else{
// No changes. Silently close transaction.
object._isDirty = null;
object._transaction = false;
}
},
/************************************
* Ownership management
*/
// Add reference to the record.
/** @private */
aquire( owner : Owner, child : Transactional, key? : string ) : void {
if( child._owner ) throw new ReferenceError( 'Trying to aquire ownership for an object already having an owner' );
child._owner = owner;
child._ownerKey = key;
},
// Remove reference to the record.
/** @private */
free( owner : Owner, child : Transactional ) : void {
if( owner === child._owner ){
child._owner = void 0;
child._ownerKey = void 0;
}
}
}
function getOwnerEndpoint( self : Transactional ) : IOEndpoint {
// Check if we are the member of the collection...
const { collection } = self as any;
if( collection ){
return getOwnerEndpoint( collection );
}
// Now, if we're the member of the model...
if( self._owner ){
const { _endpoints } = self._owner as any;
return _endpoints && _endpoints[ self._ownerKey ];
}
}