Skip to content

Instantly share code, notes, and snippets.

@SaladHut
Last active June 23, 2022 19:35
Show Gist options
  • Save SaladHut/27ffaa24bd2e83863df2dbaf6b1fd23c to your computer and use it in GitHub Desktop.
Save SaladHut/27ffaa24bd2e83863df2dbaf6b1fd23c to your computer and use it in GitHub Desktop.
WIP firestore real time class wrapper

MLIData

Reactive Properties

You can map a class to a firestore resource by subclassing MLIData and define a resource path using @resource() and each properties using @property() (TODO, support field path)

Example

MyUser uses @resources('users') to create and map an instance to /users/alice

import { MLIData as Data, resource, property } from './MLIData';

@resource('users')
export class MyUser extends Data {
  @property() name;
  @property() email;
  @property() phone;
}

const alice = new MyUser('alice');
console.log('name:', alice.name, 'email:', alice.email );
alice.phone = '+66999999999';

The example also declare 3 reactive properties. All firestore updates of the resource will update these values and all set will write back onto firestore.

Auto-ID

If you don't provide an id, the document will be auto-generated whenever you start setting a reactive property. (If you don't set a reactive property, the doc won't be generated at all)

const bob = new MyUser();
console.log( bob.id );
bob.name = 'Bob';
bob.ready.then( bob => console.log( 'auto-id:', bob.id, 'name:', bob.name ));

Listen to changes

For now you can only know when the object got updated by waiting for .on to be resolved. This example test that after an update, alice still the current user or not. (You may also set property options @property(options) with { updated: myUpdatedFunction } to track for value changed with your provided callback)

const alice = this.currentUser;
alice.on.then( alice => console.log( this.currentUser === alice ));

You can call .on.then() multiple times they will be called at the same time. This allows you to share alice instance among other elements or controllers without having to duplicate the instance. (You still can have dupes, and they will all be in synced and if you update one, the duplicates will also receive .on updates, either locally or remotely, across browser.

.on was implemented using MLIResume.

alice.on.then( alice => console.log( alice.name ));
alice.on.then( alice => console.log( alice.phone ));
alice.on.then( alice => console.log( alice.email ));

Property Options

Converter

const myConverter = {
  fromDatabase(){},
  toDatabase(){},
}

@property({ converter: myConverter })

...

LitElement's ReactiveController

To make a data as a controller, you can override updateNext

@resource('test-data')
class Data extends MLIData {
  @property() propA;
  @property() propB;

  constructor( host, dataId ) {
    super( dataId );
    ( this.host = host ).addController( this );
  }

  hostDisconnected() {
    this.host = null;
  }

  hostConnected() {
    this.ready.then(() => this.host.requestUpdate());
  }

  updateNext( ...args ){
    super.updateNext( ...args );
    this.ready.then(() => this.host.requestUpdate());
  }
}

ready ensures synchronized server data and local updates. If you don't need that you can just use the object as it is.

Keep data separated

I'd prefer keeping the data separated from the controller.

@resource('cats')
class Cat extends MLIData {
  @property() name;
  @property() meowCount;
}

class CatController {

  constructor( host, cat ) {
    ( this.host = host ).addController( this );
    this.cat = cat;
  }

  hostDisconnected() {
    this.host = null;
  }

  hostConnected() {
      for await ( const c of this.cat.track()){
        this.host.requestUpdate();
      }
  }

}

//...

ctrl = new CatController( this, new Cat());
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) }...}`;
}
}
import { getFirestore, collection, doc, getDocs, setDoc, addDoc, updateDoc, getDoc, onSnapshot, serverTimestamp, Timestamp } from 'firebase/firestore';
import { getAuth, onAuthStateChanged } from 'firebase/auth';
import merge from 'deepmerge';
import { isPlainObject } from '../functions';
import { MLIResourceType } from './MLIResourceType';
import { MLIResume as Resume } from '../task';
const db = getFirestore();
export class MLIDataList extends MLIResourceType {
get resourceType(){
return this.__resourceType;
}
__sync = new Resume();
// Contains id => reference struct which contains
// resource, and info (read-only) data.
_list = new Map();
constructor( resourceRoot, resourceType ){
super();
this.__resourceRoot = resourceRoot;
this.__resourceType = resourceType;
console.debug('🔢 new:', this ,'root:', resourceRoot, 'type:', resourceType );
this.query();
}
query( /*TODO*/ ){
this._subscribeResourcePath();
}
updateError( error ){
//FIXME
console.debug('🔢 error:', error );
this._unsubscribeResourcePath();
this.__sync.reject( error );
}
next( snapshot ){
const updatedTS = this._hasUpdatedTS = new Date();
if( !this.__unsub ) return;
this._hasUpdatedTS = new Date();
//this.updateNext( doc, this._hasUpdatedTS );
//const newUpdates = [];
const list = this._list;
snapshot.docChanges().forEach(( change ) => {
const type = change.type;
const id = change.doc.id;
const updates = change.doc.data();
console.debug('🔢 change type:', type, updates );
const reference = list.get( change.doc.id ) || {
/*
info: {}, // for keeping read-only data
resource: null, // for keeping the actual data object.
metadata: null, // /user/{userId}/{resourceType}/{doc.id}
*/
updateTypes: {},
// clients may store custom info, like last update timestamp, if needed.
clients: new WeakMap(),
};
list.set( id, reference );
reference.updateTypes[ type ] = updatedTS;
reference.info = merge(
reference.info, updates,
{ isMergeableObject: isPlainObject },
);
const info = reference.info;
const resource = reference.resource;
//newUpdates.push({ id, type, updates, info, ...( resource && { resource })});
});
//this.__updates = newUpdates;
this.__sync.resolve( this );
}
keys(){
return this._list.keys();
}
values(){
return this._list.values();
}
get size(){
return this._list.size;
}
[ Symbol.iterator ](){
return this._list[Symbol.iterator]();
/*
const entries = [...this._list ];
let index = 0;
return {
next: () => {
if( index < entries.length ){
return { value: entries[ index++ ], done: false }
} else {
return { done: true }
}
}
}
*/
}
refer( resource ){
const reference = this.list.get( data.id );
if( reference ){
reference.resource = resource;
} else {
this.list.set( id, { resource });
}
}
/*
get updates(){
return this.__updates;
}
*/
get on(){
return this.__sync.reset();
}
get ready(){
if( this.hasUpdated ){
return Promise.resolve( this );
}
return this.__sync.reset();
}
/*
async *track(){
if( this.id ){
if( this.updating ){
const data = await this.__sync.reset();
yield data;
} else {
console.debug(`DataList: yield immediately`);
yield this;
}
}
while( true ){
console.debug(`Data: continue tracking...`);
try {
const data = await this.__sync.reset();
console.debug(`DataList: yield`, data );
yield data;
} catch ( error ){
console.debug(`DataList: XXX error tracking:`,error);
break;
}
}
console.debug(`DataList: end tracking:`);
}
*/
}
import { notEqual } from '../functions';
const ph = v => v;
/*
options
* hasChanged, allow custom equal test function.
TODO, may be.
* converter, guess we'll need this one to convert date to timestamp.
*/
export class MLIDataProperty {
constructor( data, name, options ){
this.data = data;
this.name = name;
this.options = options;
this.hasChanged = options?.hasChanged || notEqual;
this.setter = options?.setter || ph;
// Work around until we implement custom setter().
this.updated = options?.updated?.bind( this.data );
/*
if( typeof options?.converter === 'function' ){
this.fromDatabase = options?.converter || ph;
this.toDatabase = options?.converter || ph;
} else {
*/
this.fromDatabase = options?.converter?.fromDatabase || ph;
this.toDatabase = options?.converter?.toDatabase || ph;
//}
//TODO
//this.validate = options?.validate || ph;
// Updating means that the property's value has not yet been passed to
// the update function. Either because the document status isn't ready
// or a property is being set to a new value. During updating all server
// updated values will be discarded and all the local values will be
// accepted and will replace the current updating value.
this.updating = false;
// Syncing show the concurrence of simutaneous updates.
// That means the updated data is being written to the backend.
// The value will be increased by one right before calling update function.
// And will be decreased by one right after the update function has finished.
}
get value(){
return this.__value;
}
set value( newValue ){
newValue = this.setter( newValue );
if( !this.hasChanged( this.__value, newValue )){
console.debug(`💾🔹 ${ this._sn } value does not change. old:`, this.__value,'new:', newValue );
return;
}
const oldValue = this.__value;
this.__value = newValue;
this.updated?.( newValue, oldValue );
// Welp.. we may store but we can't update undefined!
if( undefined === newValue ){
return;
}
this.requestUpdate();
}
/*
Called to update local data to server updated value.
TODO support update path
*/
receiveUpdate( newData, isLocal ){
const serverValue = newData[ this.name ];
if( serverValue === undefined ){
console.debug(`💾🔹 ${ this._sn } ignore undefined update. local: ${ isLocal }`);
return;
}
const newValue = this.fromDatabase( serverValue );
console.debug(`💾🔹 ${ this._sn } got value`, newValue, 'local:', isLocal );
if( this.updating ){
console.debug(`💾🔹 ${ this._sn } is updating. Skip server value.` );
if( !this.hasChanged( this.__value, newValue )){
console.debug(`💾🔹 ${ this._sn } matched server value, stop request as well.` );
this.stopUpdate();
}
return;
}
if( this.hasChanged( this.__value, newValue )){
console.debug(`💾🔹 ${ this._sn } new value:`, this.__value, '⟶', newValue );
const oldValue = this.__value;
this.__value = newValue;
if( this.updated ){
this.updated( newValue, oldValue );
}
}
}
stopUpdate(){
this.updating = false;
return this.__value;
}
/*
Triggered by setting value through setter.
Wait for this.data to make sure that doc with the data id exists
before updating the backend.
*/
async requestUpdate(){
// prevent multiple updates.
if( this.updating ){
console.debug(`💾🔹 ${ this._sn } is already updating.`);
return;
}
this.updating = true;
console.debug(`💾🔹 ${ this._sn } requestUpdate`, this.value );
if( !this.data.hasUpdated ){
let docId = this.data.id;
const docSync = this.data.docSync;
if( docId === undefined ){
// Initializer, wait for constructor.
// docId = await docSync.reset();
docId = await docSync.reset();
}
if( docId === null ){
// If docId is null, it means that it has been unblocked
// by the constructor to allow it to createData because
// data id was not presence. Document need auto-id;
await this.data._createData();
} else {
// Wait for next() to test if doc exists
// and resolve doc id.
await docSync.reset();
}
// In summary, this part can be unblocked by
// (a) Data constructor, by resoving id to null to allow
// the updaters to auto-createData() on the back-end.
// (b) id setter, after getting first normal next() update
// as the doc has been proved to exist for the id.
// (c) in next(), by generating a new doc for the given id.
// This is for creating a doc with decided id. eg. when matching
// metadata name to a doc name.
console.debug(`💾🔹 ${ this._sn } got id:`, this.data.id );
}
// Allow stopping update, this could be used by _createData()
// and (set doc()) to prevent double writing.
if( !this.updating ){
console.debug(`💾🔹 ${ this._sn } updating has been stopped.`);
return;
}
// Turn updating off here to allow later changes while await for the server update.
this.updating = false;
this.syncing++;
console.debug(`💾🔹 ${ this._sn } _updateProperty.`);
await this.data._updateProperty( this );
this.syncing--;
console.debug(`💾🔹 ${ this._sn } done syncing.`);
}
/*
Setting synching will update Data's syncing property to allow
Data to decide on when to call sync.resolve() to update GUI.
*/
__syncing = 0;
get syncing(){
return this.__syncing;
}
set syncing( newSyncing ){
const oldSyncing = this.__syncing;
this.__syncing = newSyncing;
if( oldSyncing === 0 && newSyncing === 1 ){
console.debug(`💾🔹 ${ this._sn } mark syncing.`);
this.data.syncing++;
} else if( oldSyncing === 1 && newSyncing === 0 ){
console.debug(`💾🔹 ${ this._sn } unmark syncing.`);
this.data.syncing--;
}
}
/*
A condition that is false if the updating buffer is active, either if it is waiting
for document to exist or waiting for value to be stored on the backend.
*/
get isSynced(){
return !this.updating && this.syncing === 0;
}
get _sn(){
return `${this.data._sn}.${ this.name }`;
}
}
//import { getFirestore, collection, doc, getDocs, setDoc, addDoc, updateDoc, getDoc, onSnapshot, serverTimestamp } from 'firebase/firestore';
//import { getAuth, onAuthStateChanged } from 'firebase/auth';
import { notEqual, isPlainObject } from '../functions';
import { MLIData } from './MLIData';
import { MLIUser as User } from './MLIUser';
//TODO unbind metadata from user path.
// eg. define custom resourceRoot property.
export class MLIMetadata extends MLIData {
constructor( data ){
const user = User.user;
if( !user ){
throw 'no user, no metadata.';
}
if( user.__metadataMap.has( data )){
return user.__metadataMap.get( data );
}
console.debug('💾Ⓜ️ new >>');
super();
User.me.then( user => {
this.__resourceRoot = `users/${ user.id }`;
data.docSync.return( id => id ).then( id => {
this.id = id;
});
user.__metadataMap.set( data, this );
});
}
}
/*
A subclass of MLIData to provide metadata (MLIMetadata).
*/
export class MLIResource extends MLIData {
/**** Metadata ****/
/**
!! Not to be confused with doc.metadata. !!
Metadata is a concept for manging references that
are also documents. They are also like sym-link
pointing to the actual document. Each user (TODO, unbind user)
may have their own links to the same resource
document data. eg.
Metadata /users/{user-id}/{resource}/{resource-id}
...is linked with
MLIResource /{resource}/{resource-id}
Metadata shares the same resource type with its MLIResource counterpart.
To declare metadata, the metadata class should derive from MLIMetadata.
@resource('my-data',
class extends MLIMetadata {
@property() metaA;
@property() metaB;
@property() metaC;
//...
}
)
class MyData extends MLIMetadata( MLIData ){
// MLIMetadata mixin is needed here.
//...
}
*/
/*
Register on-demand to the resource, one metadata per user per resource.
This will register the data -> metdata on user's weak map.
*/
get metadata() {
return new this.constructor.__metadataClass( this );
}
}
import { getFirestore, collection, doc, onSnapshot } from 'firebase/firestore';
//import { getAuth, onAuthStateChanged } from 'firebase/auth';
const db = getFirestore();
export class MLIResourceType {
//static metaclassMap = new Map();
/* TODO implement MLIResourceController
constructor( source ){
this.source = source;
}
get source(){
return this.__source;
}
set source( newSource ){
this.__source.unsubscribe( this );
this.__source = newSource;
newSource.subscribe( this );
}
*/
_hasUpdatedTS = new Date( 0 );
get hasUpdated(){
return this._hasUpdatedTS.getTime() !== 0;
}
get resourceType(){
return this.__resourceType || this.constructor.__resourceType;
}
get resourcePath(){
return [...this.__resourceRoot.split('/')
.filter( pathElement => pathElement ), this.resourceType ].join('/');
}
next( doc ){
// Prevent any late update.
if( !this.__unsub ) return;
if( !doc.exists()){
// id was defined but the doc doesn't exist so we must _createData.
// This is for creating a doc with decided id. eg. when matching
// metadata name to a doc name.
console.debug(`💾🔷 ${ this._sn } doc not exists next _createData` );
this._createData();
} else {
this._hasUpdatedTS = new Date();
this.updateNext(
doc.data(),
doc.metadata.hasPendingWrites,
);
}
}
error( err ){
console.debug(`💾🔶 error:`, err );
this.updateError( err );
}
_subscribeResourcePath( dataId ){
if( this.__unsub ){
console.debug(`💾🔶 already subscribed` );
return;
}
if( dataId ){
console.debug(`💾🔶 subscribe resource:`, doc( db, this.resourcePath, dataId ).path );
this.__unsub = onSnapshot( doc( db, this.resourcePath, dataId ), this );
} else {
this.__isCollection = true;
console.debug(`💾🔶 subscribe collection:`, collection( db, this.resourcePath ).path );
this.__unsub = onSnapshot( collection( db, this.resourcePath ), this );
}
// will be calling this.next() / this.error()
}
_unsubscribeResourcePath(){
console.debug(`💾🔶 unsub!`);
const unsub = this.__unsub;
this.__unsub = null;
this._hasUpdatedTS = new Date( 0 );
unsub();
}
get isListening(){
return this.__unsub ? true: false;
}
}
import { getAuth, onAuthStateChanged, signOut, signInAnonymously, signInWithPopup, OAuthProvider, GoogleAuthProvider, linkWithPopup} from 'firebase/auth';
import { serverTimestamp } from 'firebase/firestore';
import { MLIResume as Resume } from '../task';
import { MLIResourceMap } from './MLIResourceMap';
import { MLIData, resource, property } from './MLIData';
@resource('users')
export class MLIUser extends MLIData {
static __auth = new Resume('auth');
static __userMap = new Map();
static __currentUser;
static get user(){
return this.__currentUser;
}
// Wait until it resolves a user.
static get me(){
if( this.__currentUser ){
return Promise.resolve( this.__currentUser );
} else {
return this.__auth.return( u => u );
}
}
static __unsubAuthChanged = null;
static get on(){
return this.__auth.reset();
}
static subscribeAuthChanged(){
this.__auth.reset();
if( !this.__unsubAuthChanged ){
this.__unsubAuthChanged = onAuthStateChanged( getAuth(), userData => {
console.debug(`👤`, userData );
if( userData ){
this.__currentUser = new this( userData.uid, userData );
this.__currentUser.resubscribe();
this.__auth.resolve( this.__currentUser );
} else {
const oldUser = this.__currentUser;
this.__currentUser = null;
this.__auth.reject( oldUser );
}
}); // FIXME use reject in error
}
return this.__auth;
}
@property() name;
@property() email;
@property() phone;
__metadataMap = new WeakMap();
// TODO also listen to data change.
constructor( uid = null, data ){
if( !uid ) throw 'User needs user id.';
if( MLIUser.__userMap.has( uid )){
const user = MLIUser.__userMap.get( uid );
user.assignUserData( data );
return user;
}
super( uid );
MLIUser.__userMap.set( uid, this );
this.assignUserData( data );
}
/** User Data **/
// onSnapshot() error
updateError( error ){
super.updateError( error );
// Looks like we lost user
console.debug(`👤 ${ this._sn } Error accessing user data`);
// Try to regain user again.
/*
MLIUser.me.then( user => {
if( this.id === user.id ){
console.debug(`👤 ${ this._sn } Has user again:`, user );
this._subscribeResourcePath( user.id );
} else {
console.debug(`👤 ${ this._sn } New user signed in:`, user, 'now what?');
}
}).catch( error => {
console.debug(`👤 ${ this._sn } Error re-subscribing.`, error, error.code );
});
*/
}
//dataReady = new Resume('user-data');
get bundles(){
return { update: serverTimestamp()};
}
async assignUserData( data ){
// Update UI properties
this.isAdmin = [
"ying@saladhut.com",
"beingying@gmail.com",
"saladhutinfo@gmail.com",
].includes( data.email );
this.isAnonymous = data.isAnonymous;
this.photoURL = data.photoURL;
// Wait for first update.
await this.ready;
// Update user profile properties if needed.
console.debug(`👤 ${ this._sn } assigns:`, data );
this.email ??= data.email;
this.name ??= data.displayName;
this.phone ??= data.phoneNumber;
await this.ready;
// Make sure that new data are flushed to backend.
//await this.ready;
//this.dataReady.resolve( data, false );
}
/** Resources **/
/*
__resourcesMap = new Map();
resources( type, clazz = ResourceMap ){
const resourceOfType = this.__resourcesMap.get( type ) || new clazz( this.id, type );
this.__resourcesMap.set( type, resourceOfType );
return resourceOfType;
}
*/
/** Signing **/
static signOut(){
return signOut( getAuth());
}
static async signInWithGoogle( anonymously = false ){
if( this.__currentUser ){
throw 'Try re-signing';
}
const provider = new GoogleAuthProvider();
const auth = getAuth();
try {
this.__currentUser = undefined;
// TODO Try resolve undefined.
//this.__auth.resolve( this.__currentUser );
const result = anonymously
? await signInAnonymously( auth )
: await signInWithPopup( auth, provider );
// This gives you a Google Access Token. You can use it to access the Google API.
console.debug(`👤 result:`, result );
const credential = GoogleAuthProvider.credentialFromResult( result );
const token = credential?.accessToken;
// The signed-in user info.
const userData = result.user;
console.debug(`👤 signInWithGoogle`, userData );
return await this.me;
} catch( error ){
// Handle Errors here.
const errorCode = error.code;
const errorMessage = error.message;
// The email of the user's account used.
const email = error.email;
// The AuthCredential type thmat was used.
const credential = GoogleAuthProvider.credentialFromError(error);
// ...
console.debug('👤 error signing google.',error );
return null;
}
}
static async linkGoogle(){
const googleProvider = new GoogleAuthProvider();
const auth = getAuth();
if( auth.currentUser.uid !== this.__currentUser?.id ) throw 'user not current';
const result = await linkWithPopup( auth.currentUser, googleProvider );
await this.__currentUser.assignUserData( result.user );
}
}
MLIUser.subscribeAuthChanged().catch( error => console.debug(`👤 catch:`, error ));
/*
_signInWithMicrosoft(){
this.waiting = true;
const provider = new OAuthProvider('microsoft.com');
const auth = getAuth();
signInWithPopup(auth, provider)
.then((result) => {
// User is signed in.
// IdP data available in result.additionalUserInfo.profile.
// Get the OAuth access token and ID Token
const credential = OAuthProvider.credentialFromResult(result);
const accessToken = credential.accessToken;
const idToken = credential.idToken;
})
.catch((error) => {
// Handle error.
console.debug('user-profile: error signing ms.',error );
this.waiting = false;
});
}
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment