typescriptalgorithmdata-bindingcircular-dependencynested-object

How to get callback when subset of nested properties change in this system?


Say I have a "type" like this:

{
  a: {
    b: {
      c: {
        d: string
        e: boolean
      }
    },
    x: string
    y: number
    z: string
  }
}

At each object node, I want to get notified if all of the children are "resolved" to a value. So for example:

const a = new TreeObject()
a.watch((a) => console.log('a resolved', a))

const b = a.createObject('b')
b.watch((b) => console.log('b resolved', b))

const c = b.createObject('c')
c.watch((c) => console.log('c resolved', c))

const d = c.createLiteral('d')
d.watch((d) => console.log('d resolved', d))

const e = c.createLiteral('e')
e.watch((e) => console.log('e resolved', e))

const x = a.createLiteral('x')
x.watch((x) => console.log('x resolved', x))

const y = a.createLiteral('y')
y.watch((y) => console.log('y resolved', y))

d.set('foo')
// logs:
// d resolved

e.set('bar')
// logs:
// e resolved
// c resolved
// b resolved

y.set('hello')
// logs:
// y resolved

x.set('world')
// logs:
// x resolved
// a resolved

That is the base case. The more complex case, which is what I've been trying to solve for, is matching against a subset of properties, like this:

// receive 'b' only if b.c.d is resolved.
// '3' for 3 args
a.watch3('b', {
  c: {
    d: true
  }
}, () => {
  console.log('b with b.c.d resolved')
})

You can have multiple "watchers" per property node, like this:

a.watch3('b', { c: { d: true } }, () => {
  console.log('get b with b.c.d resolved')
})

a.watch3('b', { c: { e: true } }, () => {
  console.log('get b with b.c.e resolved')
})

a.watch2('x', () => {
  console.log('get x when resolved')
})

// now if were were to start from scratch setting properties fresh:
x.set('foo')
// logs:
// get x when resolved

e.set('bar')
// logs:
// get b with b.c.e resolved

How can you neatly set this up? I have been trying for a long time to wrap my head around it but not getting far (as seen in this TS playground.

type Matcher = {
  [key: string]: true | Matcher
}

type Callback = () => void

class TreeObject {
  properties: Record<string, unknown>

  callbacks: Record<string, Array<{ matcher?: Matcher, callback: Callback }>>

  parent?: TreeObject

  resolved: Array<Callback>

  constructor(parent?: TreeObject) {
    this.properties = {}
    this.callbacks = {}
    this.parent = parent
    this.resolved = []
  }

  createObject(name: string) {
    const tree = new TreeObject(this)
    this.properties[name] = tree
    return tree
  }
  
  createLiteral(name: string) {
    const tree = new TreeLiteral(this, () => {
      // somehow start keeping track of decrementing what we have matched so far
      // and when it is fully decremented, trigger the callback up the chain.
    })
    this.properties[name] = tree
    return tree
  }

  watch3(name: string, matcher: Matcher, callback: Callback) {
    const list = this.callbacks[name] ??= []
    list.push({ matcher, callback })
  }

  watch2(name: string, callback: Callback) {
    const list = this.callbacks[name] ??= []
    list.push({ callback })
  }

  watch(callback: Callback) {
    this.resolved.push(callback)
  }
}

class TreeLiteral {
  value: any

  parent: TreeObject

  callback: () => void

  resolved: Array<Callback>

  constructor(parent: TreeObject, callback: () => void) {
    this.value = undefined
    this.parent = parent
    this.callback = callback
    this.resolved = []
  }

  set(value: any) {
    this.value = value
    this.resolved.forEach(resolve => resolve())
    this.callback()
  }

  watch(callback: Callback) {
    this.resolved.push(callback)
  }
}

const a = new TreeObject()
a.watch(() => console.log('a resolved'))

const b = a.createObject('b')
b.watch(() => console.log('b resolved'))

const c = b.createObject('c')
c.watch(() => console.log('c resolved'))

const d = c.createLiteral('d')
d.watch(() => console.log('d resolved'))

const e = c.createLiteral('e')
e.watch(() => console.log('e resolved'))

const x = a.createLiteral('x')
x.watch(() => console.log('x resolved'))

const y = a.createLiteral('y')
y.watch(() => console.log('y resolved'))

d.set('foo')
// logs:
// d resolved

e.set('bar')
// logs:
// e resolved
// c resolved
// b resolved

y.set('hello')
// logs:
// y resolved

x.set('world')
// logs:
// x resolved
// a resolved

How can you define the watch3 and related methods to accept their "matchers" and callback, and properly call the callback when the matchers' properties are all fulfilled?

It gets tricky because you can work in two directions:

  1. The value could have already been resolved in the past, before you added your watchers/listeners. It should still be notified right away in that case.
  2. The value can be resolved in the future, after you added your watchers. It should be notified only once fulfilled.

Note, the "matcher" syntax is sort of like a GraphQL query, where you simply build an object tree with the leaves set to true on what you want.


Solution

  • Some preliminary thoughts:

    Here is how that could be coded:

    type Matcher = true | {
        [key: string]: Matcher
    };
    
    type Callback = () => void;
    
    type Listener = { callback: Callback, matcher: Matcher };
    
    type TreeNode = TreeObject | TreeLiteral;
    
    abstract class TreeElement  {
        #parent?: TreeObject;
        #unresolvedCount = 0;
        #hasLiterals = false;
        #callbacks: Array<Callback> = [];
        
        constructor(parent?: TreeObject) {
            this.#parent = parent;
        }
    
        notify(isResolved: boolean) { // bubbles up from a TreeLiteral, when created and when resolved
            if (isResolved) {
                this.#unresolvedCount--;
                if (this.#unresolvedCount == 0) {
                    for (const cb of this.#callbacks.splice(0)) {
                        cb();
                    }
                }
            } else {
                this.#unresolvedCount++;
                this.#hasLiterals = true;
            }
            this.#parent?.notify(isResolved); // bubble up
        }
        
        watch(callback: Callback) {
            if (this.#hasLiterals && this.#unresolvedCount == 0) {
                callback();
            } else {
                this.#callbacks.push(callback);
            }
        }
    
    }
    
    class TreeObject extends TreeElement {
        #properties: Record<string, TreeNode> = {};
        #pendingMatchers: Record<string, Array<Listener>> = {};
    
        #attach(name: string, child: TreeNode) {
            this.#properties[name] = child;
            // If this name is used by one or more pending matchers, remove them as pending,
            //   and watch the nested matcher(s) on the newly created child.
            if (this.#pendingMatchers[name]) {
                for (const {callback, matcher} of this.#pendingMatchers[name].splice(0)) {
                    child.watch(callback, matcher);
                }
            }
        }
    
        createObject(name: string) {
            if (this.#properties[name]) throw new Error(`Cannot create ${name}: it is already used`);
            const obj = new TreeObject(this);
            this.#attach(name, obj);
            return obj;
        }
    
        createLiteral(name: string) {
            if (this.#properties[name]) throw new Error(`Cannot create ${name}: it is already used`);
            const obj = new TreeLiteral(this);
            this.#attach(name, obj);
            return obj;
        }
    
        watch(callback: Callback, matcher: Matcher=true) {
            if (matcher === true) {
                super.watch(callback);
            } else {
                let counter = Object.keys(matcher).length;
                // Create a new callback that will call the original callback when all toplevel
                //   entries specified by the matcher have been resolved.
                const newCallback = () => {
                    counter--;
                    if (counter == 0) {
                        callback();
                    }
                };
                for (const key of Object.keys(matcher)) {
                    if (this.#properties[key]) {
                        this.#properties[key].watch(newCallback, matcher[key]);
                    } else { // suspend the watch until the structure is there
                        (this.#pendingMatchers[key] ??= []).push({
                            callback: newCallback,
                            // Copy the matcher so the caller cannot mutate our matcher
                            matcher: JSON.parse(JSON.stringify(matcher[key]))
                        });
                    }
                }
            }
    
        }
    }
    
    class TreeLiteral extends TreeElement {
        #literalValue: any;
    
        constructor(parent?: TreeObject) {
            super(parent);
            this.notify(false); // Notifiy to the ancestors that there is a new literal
        }
    
        set(value: any) {
            this.#literalValue = value;
            this.notify(true); // Notifiy to the ancestors that this literal resolved
        }
    
        valueOf() {
            return this.#literalValue;
        }
    
        watch(callback: Callback, matcher: Matcher=true) {
            if (matcher === true) {
                super.watch(callback);
            } // else, the matcher references an endpoint that will never be created
        }
    }
    

    See it with some test functions on TS Playground