|
import { getFirestore, collection, doc, getDocs, setDoc, addDoc, updateDoc, getDoc, onSnapshot } from 'firebase/firestore'; |
|
import { getAuth, onAuthStateChanged } from 'firebase/auth'; |
|
import merge from 'deepmerge'; |
|
|
|
import { notEqual, isPlainObject } from '../functions'; |
|
import { MLIResume as Resume } from '../task'; |
|
import { MLIResourceType } from './MLIResourceType'; |
|
import { MLIDataProperty as DataProperty } from './MLIDataProperty'; |
|
|
|
function makeSetArray( array ){ |
|
return [...new Set( array )].sort(); |
|
} |
|
|
|
export const SetArray = { |
|
fromDatabase: makeSetArray, |
|
toDatabase: makeSetArray, |
|
}; |
|
|
|
function makeSortedArray( array ){ |
|
return [...array].sort(); |
|
} |
|
|
|
export const SortedArray = { |
|
fromDatabase: makeSortedArray, |
|
toDatabase: makeSortedArray, |
|
}; |
|
|
|
export function property( options ){ |
|
return ( descriptor, name ) => { |
|
|
|
if( name !== undefined ){ |
|
throw 'legacy what?!'; |
|
} |
|
|
|
const propName = descriptor.key; |
|
|
|
return { |
|
kind: 'field', |
|
key: Symbol(), |
|
placement: 'own', |
|
descriptor: {}, |
|
originalKey: propName, |
|
initializer(){ |
|
|
|
// Generate data holder. |
|
this[`__prop_${ propName }`] = new DataProperty( this, propName, options ); |
|
|
|
if( typeof descriptor.initializer === 'function') { |
|
this[ propName ] = descriptor.initializer.call( this ); |
|
} |
|
|
|
}, |
|
finisher( clazz ){ |
|
clazz.createProperty( propName, options ); |
|
}, |
|
}; |
|
} |
|
} |
|
|
|
export function resource( resourceType, metadataClass ){ |
|
return function decorator( descriptor ){ |
|
const { kind, elements } = descriptor; |
|
return { |
|
kind, |
|
elements, |
|
finisher( clazz ){ |
|
clazz.__resourceType = resourceType; |
|
console.debug(`💾ℹ️ Register: ${ clazz.name } {${resourceType}}`); |
|
if( metadataClass ){ |
|
/* |
|
if( MLIResourceType.metaclassMap.has( resourceType )){ |
|
console.debug(`💾ℹ️ XXXX Replacing metadata class: ${ clazz.name } {${resourceType}}`); |
|
} |
|
MLIResourceType.metaclassMap.set( resourceType, clazz.Metadata ); |
|
console.debug(`💾ℹ️ Register: ${ clazz.name }.Metadata {${resourceType}}`); |
|
*/ |
|
metadataClass.__resourceType = resourceType; |
|
clazz.__metadataClass = metadataClass; |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
const db = getFirestore(); |
|
|
|
@resource('mli-data') |
|
export class MLIData extends MLIResourceType { |
|
static __resourceType = 'TEMP'; |
|
|
|
static get propertySet(){ |
|
return this.__propertySet; |
|
} |
|
|
|
/**** Properties ****/ |
|
|
|
/* |
|
Example: |
|
|
|
@property({wow:20202020}) testA; |
|
@property() testB; |
|
|
|
*/ |
|
|
|
static createProperty( propName, options ){ |
|
|
|
const propKey = `__prop_${ propName }`; |
|
const descriptor = { |
|
get(){ |
|
return this[ propKey ].value; |
|
}, |
|
set( value ){ |
|
this[ propKey ].value = value; |
|
/* |
|
const prop = this[ propKey ]; |
|
const oldValue = prop.value; |
|
prop.value = value; |
|
this.__requestUpdate( prop, oldValue ); |
|
*/ |
|
//this[ propKey ].value = value; |
|
}, |
|
configurable: true, |
|
enumerable: true, |
|
}; |
|
|
|
Object.defineProperty( this.prototype, propName , descriptor ); |
|
|
|
if( !this.hasOwnProperty('__propertySet')){ |
|
console.debug(`💾ℹ️ Create propertySet for: ${ this.name }`); |
|
this.__propertySet = new Set(); |
|
} |
|
|
|
console.debug(`💾ℹ️ ${ this.name } defines ${ propName }`); |
|
this.__propertySet.add( propName ); |
|
} |
|
|
|
getProperty( key ){ |
|
return this[`__prop_${ key }`] |
|
} |
|
|
|
get propertyList(){ |
|
return [...this.constructor.propertySet ]; |
|
} |
|
|
|
/* true if any prop is updating */ |
|
get isSynced(){ |
|
return this.hasUpdated && !this.isUpdating && this.syncing === 0; |
|
} |
|
|
|
get isUpdating(){ |
|
return this.propertyList.some( name => this[`__prop_${ name }`].updating ); |
|
} |
|
|
|
/**** Constructor ****/ |
|
|
|
/* |
|
To auto generate id, pass null as argument, eg. new Data( null ) |
|
|
|
If dataId is undefined, all properties will be blocked from |
|
requestUpdate until id is resolved to null or an actual id. |
|
This allow late id decision without blocking the data from |
|
setting the properties. (eg. metadata needs time to resolve |
|
user id, as null would allow properties to trigger updating.) |
|
|
|
See set id() for details. |
|
*/ |
|
constructor( dataId, resourceRoot = ''){ |
|
super(); |
|
|
|
if( dataId === undefined ){ |
|
console.debug(`💾🔷 ${ this.constructor.name } Construct pending data.` ); |
|
} |
|
|
|
this.sync = new Resume('sync'); |
|
this.__resourceRoot = resourceRoot; |
|
|
|
this.id = dataId; |
|
} |
|
|
|
/* |
|
docSync can be resolved once or twice. If resolve( null), |
|
doc.id will never be privided so a document data needed |
|
to be generated. If otherwise, doc alredy exists in the backend. |
|
|
|
docSync is used to block updaters from creating new data |
|
until we know if won't have a doc.id or not. |
|
*/ |
|
|
|
get docSync(){ |
|
return ( this.__docSync ??= new Resume('doc')); |
|
} |
|
|
|
__idValue = undefined; |
|
get id(){ |
|
return this.__idValue; |
|
} |
|
set id( dataId ){ |
|
|
|
// Reassigning different id is not allowed. |
|
// Only null is allowed for auto-generate id. |
|
|
|
if( this.__idValue ){ |
|
if( this.__idValue !== dataId ){ |
|
throw new Error(`Try to reassign data id ${ this.__idValue } with ${ dataId }.`); |
|
} |
|
|
|
return; |
|
} |
|
|
|
// Setting id to null will unblock initializers/setters |
|
// from id === undefined state, allowing them to test if |
|
// id === null and generate the document. |
|
|
|
if( dataId === null ){ |
|
// Only trigger if the previous state is undefined. |
|
if( this.__idValue === undefined ){ |
|
this.__idValue = null; |
|
|
|
// Release blocked initializers at undefined state |
|
// so they can test if id is null and generate the document. |
|
|
|
console.debug(`💾🔷 ${ this._sn } set id: unblocks intializers.` ); |
|
this.docSync.resolve( null ); |
|
|
|
// After unblocking, wait for _createData to resolve docSync. |
|
this.docSync.reset().then( genId => { |
|
console.debug(`💾🔷 ${ this._sn } set id: got gen id:`, genId ); |
|
this.__idValue = genId; |
|
this._subscribeResourcePath( genId ); |
|
}); |
|
} else if( this.__idValue !== null ){ |
|
throw new Error(`Try to reassign data id ${ this.__idValue } with ${ dataId }.`); |
|
} |
|
} else if( dataId ){ |
|
this.__idValue = dataId; |
|
|
|
// Assigning an actual id above will start the listener |
|
// and will receive the doc status by next() which may |
|
// also call _createData() if the doc doesn't exist. |
|
// Once the doc was created or exists, it will finally |
|
// resolve sync and eventually resolve docSync here and |
|
// all the blocked properties will be allowed to finish |
|
// their requestUpdates. |
|
|
|
// Register for doc |
|
this.sync.reset().then(() => { |
|
this.docSync.resolve( dataId, false ); |
|
}); |
|
|
|
// Subscribe to the listener |
|
console.debug(`💾🔷 ${ this._sn } set id: subscribes path` ); |
|
this._subscribeResourcePath( dataId ); |
|
} /* else undefined, just wait doing nothing */ |
|
|
|
} |
|
|
|
resubscribe(){ |
|
if( this.id ){ |
|
this._subscribeResourcePath( this.id ); |
|
} |
|
} |
|
|
|
/**** Listening ****/ |
|
|
|
// onSnapshot() error |
|
updateError( error ){ |
|
console.debug(`💾🔷 ${ this._sn } Error:`, error ); |
|
//if( error.code === 'permission-denied'){ |
|
this._unsubscribeResourcePath(); |
|
this.constructor.propertySet.forEach( name => { |
|
const prop = this[`__prop_${ name }`]; |
|
prop.updating = false; |
|
}); |
|
this.docSync.release(); |
|
//} |
|
this.sync.reject( error ); |
|
|
|
/* |
|
User.me.then( user => { |
|
console.debug(`💾🔷 ${ this._sn } Has user again:`, user ); |
|
this._subscribeResourcePath( user.id ); |
|
}).catch( error => { |
|
console.debug(`💾🔷 ${ this._sn } Error resubscribing.`, error, error.code ); |
|
}); |
|
*/ |
|
} |
|
|
|
setProperties( data ){ |
|
for( const key in data ){ |
|
if( this.constructor.propertySet.has( key )) |
|
this[ key ] = data[ key ]; |
|
} |
|
} |
|
|
|
// onSnapshot() next |
|
updateNext( data, isLocal ){ |
|
|
|
// __data is mainly for debugging ATM. |
|
/* |
|
this.__data = merge( this.__data, data, { |
|
isMergeableObject: isPlainObject, |
|
customMerge: this.__dataMerge, |
|
}); |
|
*/ |
|
|
|
|
|
// TODO consider using this with persistence local database. |
|
|
|
console.debug(`💾🔷 ${ this._sn } next local:${ isLocal }, got data:`, data ); |
|
|
|
this.constructor.propertySet.forEach( name => { |
|
|
|
// TODO support fieldPath by having an extra name in prop |
|
|
|
const prop = this[`__prop_${ name }`]; |
|
|
|
prop.receiveUpdate( data, isLocal ); |
|
|
|
// Since we don't update on metadata, |
|
// isLocal should only be true while prop is updating? |
|
// so this should always be true. |
|
// useful?? |
|
// prop.local = isLocal; |
|
|
|
}); |
|
|
|
// Flush listeners. If not syncing, the syncing prop is responsible |
|
// for resolving this. Here we trigger the syncing setter to sync if ready. |
|
|
|
this.sync.resolve( this ); |
|
|
|
this.syncing = this.syncing; |
|
} |
|
|
|
|
|
/* |
|
This allows waiting for properties to update their syncing status |
|
before we notify the waiting GUI updates. |
|
*/ |
|
__syncCounter = 0; |
|
get syncing(){ |
|
return this.__syncCounter; |
|
} |
|
|
|
set syncing( value ){ |
|
this.__syncCounter = value; |
|
if( value === 0 ){ |
|
console.debug(`💾🔷 ${ this._sn } sync!`, this.isSynced ); |
|
// re-resolve to allow UI to update on syncing status. |
|
this.sync.resolve( this ); |
|
} else if ( value < 0 ){ |
|
throw 'sync counter mismatched!'; |
|
} |
|
} |
|
|
|
|
|
/* |
|
Called by properties. Buffer and flush updates for the list of requested properties. |
|
*/ |
|
async _updateProperty( prop ){ |
|
let resolveUpdate; |
|
|
|
if( this.__updatingProps ){ |
|
this.__updatingProps.add( prop ); |
|
return this.__updateComplete; |
|
} |
|
|
|
this.__updatingProps = new Set().add( prop ); |
|
this.__updateComplete = new Promise(( res, rej ) => resolveUpdate = res ); |
|
|
|
// Gathering the incoming requests. |
|
// TODO test and see if it actually works. |
|
await new Promise( r => setTimeout( r )); |
|
|
|
const finalData = { |
|
...this.bundles, |
|
}; |
|
this.__updatingProps.forEach( prop => finalData[ prop.name ] = prop.toDatabase( prop.value )); |
|
this.__updatingProps = null; |
|
|
|
try { |
|
console.debug(`💾🔷 ${ this._sn } update:`, finalData ); |
|
await updateDoc( doc( db, this.resourcePath, this.id ), finalData ); |
|
} catch( error ){ |
|
console.debug(`💾🔷 XXXXX ${ this._sn } error updating:`, error, 'code:', error.code ); |
|
//FIXME UNSUB |
|
} finally { |
|
console.debug(`💾🔷 ${ this._sn } update: done` ); |
|
resolveUpdate(); |
|
} |
|
} |
|
|
|
/** For listening to changed **/ |
|
get on(){ |
|
return this.sync.reset(); |
|
} |
|
|
|
/* |
|
async is( expected ){ |
|
const result = await this.sync.reset(); |
|
if( result === expected ){ |
|
|
|
} |
|
} |
|
*/ |
|
|
|
/** Make sure the data state is updated **/ |
|
get ready(){ |
|
if( !this.isSynced ){ |
|
// New update will come soon. So just wait. |
|
|
|
// TODO may be it's better to sync all elements with |
|
// local update first. |
|
|
|
return this.sync.reset().then( id => this ); |
|
} else { |
|
// Already subscribe, so it's either generating |
|
// or set id() is waiting to resolve docSync from |
|
// its first sync.reset(). |
|
return this.docSync.then( id => this ); |
|
} |
|
} |
|
|
|
async *updates(){ |
|
if( this.isSynced ){ |
|
yield this; |
|
} |
|
|
|
while( true ){ |
|
console.debug(`💾🔷 continue tracking...`); |
|
try { |
|
const data = await this.sync.reset(); |
|
console.debug(`💾🔷 yield`, data ); |
|
yield data; |
|
} catch ( error ){ |
|
console.debug(`💾🔷 XXX error tracking:`,error); |
|
break; |
|
} |
|
} |
|
|
|
console.debug(`💾🔷 end tracking:`); |
|
} |
|
|
|
/**** Backend ****/ |
|
|
|
get bundles(){ |
|
return null; |
|
//return { update: serverTimestamp()}; |
|
} |
|
|
|
/* |
|
TODO If we are going to create doc with current data, |
|
turn off '.updating' on all properties so they know they |
|
don't need to update the values after the data creation. |
|
Subclass should override and provide initial data. |
|
|
|
We will need a way to allow multiple property settings before |
|
actually triggering _createData. |
|
*/ |
|
|
|
// _createData can be call only once. |
|
_createData = ( data ) => { |
|
this._createData = () => this.docSync.reset(); |
|
return this.__createData( data ); |
|
} |
|
|
|
// TODO FIXME there can be chances that more than one Data instance are |
|
// creating data at the same time. __createData should try to deal with this |
|
// eg. by setting merge flag or use transaction to ensure the singularity. |
|
|
|
async __createData( data = { |
|
...this.bundles, |
|
...this.propertyList |
|
.reduce(( pv, p ) => pv = { |
|
...pv, |
|
...( this[ p ] && {[ p ]: this[ p ]}) |
|
},{} |
|
) |
|
}){ |
|
|
|
console.debug(`💾🔷 ${ this._sn } _createData creates doc ${ this.resourceType } with`, {...data}); |
|
|
|
// if no id was defined, generate auto-id for the doc. |
|
const docRef = this.id |
|
? doc( db, this.resourcePath, this.id ) |
|
: doc( collection( db, this.resourcePath )); |
|
|
|
await setDoc( docRef, data ); |
|
|
|
// Solve |
|
this.docSync.resolve( docRef.id, false ); |
|
|
|
console.debug(`💾🔷 ${ this._sn } _createData generated ${ docRef.id }`); |
|
|
|
// If id wasn't already defined, |
|
// this will eventually resolve __docSync |
|
//this.id ??= docRef.id; |
|
|
|
console.debug(`💾🔷 ${ this._sn } _createData done ${ docRef.id }`); |
|
return docRef.id; |
|
} |
|
|
|
/**** Utils ****/ |
|
get _sn(){ |
|
if( !this.id ) return '[.???.]'; |
|
return `{${ this.id.substring(0,5) }...}`; |
|
} |
|
|
|
} |