Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Control child Microstate creation #357

Open
cowboyd opened this issue Apr 24, 2019 · 13 comments
Open

Control child Microstate creation #357

cowboyd opened this issue Apr 24, 2019 · 13 comments

Comments

@cowboyd
Copy link
Member

cowboyd commented Apr 24, 2019

There are many case where we need to specify how a child microstate is created from the parent. A simple case is where you want a microstate to have a default value if none is specified. For example, if I have a counter that I want to start at 1, I would use create + the default value.

class Counter {
  count = create(Number, 1);
}

Another example is one where we want to pass a value down from one microstate to another. For example, if we need to pass the bluetooth service id down to one of its characteristics, then we'd have to use an initializer.

class Service {
  characteristics = [Characteristic]

  initialize(value) {
    if (this.characteristics.length > 0 && value.characteristics[0].serviceUUID == null) {
      return value.characteristics.map(c => Object.assign({}, c, {serviceUUID: value.uuid}))
    }
    return this;
  }
}

This is ugly, and while there are other ways to achieve this, it's not very clear what's going on. Particularly because we are having to fiddle with the parent value in order to achieve a result in the child microstate.

So we have two one-off use-cases that leverage existing features that were not really created explicitly for those reasons, but we have to use them because they're kinda just laying around.

What if instead we had a general mechanism for determining how microstates are related to each other, and what is better for a general interface than a function? What if we had a function called relationship that was used to specify how to traverse a property from a parent microstate to a child microstate? We could use this for both of the use cases above.

To set a default value:

import { relationship } from 'microstates';

class Counter {
  count = relationship((counter, value) => create(Number, value || 1));
}

the relationship function receives the parent instance (counter instanceof Counter) and the value of the relationship, and is expected to return a microstate representing the relationship.

We can use this low-level function to write a "macro" relationship like defaults that does what we did above, except more intent-fully.

import { relationship } from 'microstates';

function defaults(Type, defaultValue) {
  return relationship((parent, value) => create(Type, value == null ? defaultValue : value));
}

class Counter {
  count = defaults(Number, 1);
}

We can also use this low-level mechanism for copying values down the tree:

class Service {
  characteristics = relationship((service, values) => create([Characteristic], values.map(v => append(v, { serviceUUID: service.uuid });
}

Again though, we can use a "macro" relationship to define this cleanly:

function copyIntoEach(Type, mappings) {
  return relationship((parent, values) => {
    let copies = Object.keys(mappings).reduce((copies, key) => append(copies, {[key]: valueOf(parent)[key])
    return create([Type], values.map(v => append(v, copies))
  });
}

class Service {
  characteristic = copyIntoEach(Characteristic, {uuid: 'serviceUUID'})
}

The implementation of copyIntoEach isn't the important thing, merely the fact that we can declaratively define really complex relationships with a little work.

Advantages of this approach is that it.

  1. lead the way for references into different parts of the data structure.
  2. will give us rich metadata about the names and types of the relationship since they are now first-class entities.
  3. ought to be compatible with decorators if they ever land :)
  4. fully compatible with TypeScript out of the gate.

Open questions

  • composability: what if we want to make a relationship that has a default value and copies values from the parent?
  • What other use-cases are there? To see if this approach couldn't get us there.
@cowboyd
Copy link
Member Author

cowboyd commented Apr 24, 2019

Another question. How should we handle deprecations?

For example, using the create syntax for initializing properties would become deprecated.

// good
class Human {
  name = String;
  pet = child(Dog);
}

// good
class Human {
  name = String;
  pet = relationship(({name}, value) => name.state === 'Bob' ? {Type: Dog, value} : {Type: Cat, value})
}

// bad
class Human {
   name = String
   pet = create(Dog);
}

@mharris717
Copy link
Contributor

Can you show what you would envision the API looking like if specifying this behavior was separate (both conceptually and on a different line) from the line that defines the child state's existence?

@mharris717
Copy link
Contributor

I think this proposal would benefit greatly from a clear separation between the DSL and Everything Else

@mharris717
Copy link
Contributor

Microstates are already observable. Can this functionality be built on top of that?

@mharris717
Copy link
Contributor

Thoughts on this as an alternative? Do not care about the specific syntax at all. I only care about the declaration of existence and our copy behavior happening in separate places.

class Service {
  characteristics = [Characteristic]
}

onDataChange(Service, service => {
  service.characteristics.map(c => c.serviceUUID.set(service.uuid))
})

@cowboyd
Copy link
Member Author

cowboyd commented Apr 25, 2019

I think this proposal would benefit greatly from a clear separation between the DSL and Everything Else

Right, I think this proposal is about introducing a single primitive, relationship() that enables externalization of the DSL. Right now, the DSL is very much coupled to the create() function, and as a result there is "one true DSL" so to speak.

What I'm proposing is to be able to make create speak in terms of relationships to other microstates, and not a specific understanding of how to instantiate those substates. In that case, traversing a relationship parent: P -> child: C would be just a function. Here's how the definition might looks in TypeScript:

type Traversal<Parent, Child> = (value: any, parent: Parent) => Child

If all create does is treat substates as just relationships to be traversed, where the traversal is dependent on the value at the child's location and then actual parent instance, then it seems to be like we have full flexibility to implement any kind of relationship.

For example, we could implement the current DSL as a relationship builder.

import relationship from './relationship';
import dsl as legacy from './dsl';

function dsl(...args) {
  return relationship(() => legacy.expand(...args));
}

And then it would be usable like so:

class Counter {
  count = dsl(Number);
}

But because microstates thinks in terms of relationships, you can use any function in place of dsl

@mharris717
Copy link
Contributor

I don't mean to pick on you, I know you wrote the sample code very fast, but my thought that copyIntoEach also declaring existance is confusing is bolstered by the fact that in your example, you forgot that it was implicitly creating an array type :)

class Service {
  characteristic = copyIntoEach(Characteristic, {uuid: 'serviceUUID'})
}

@cowboyd
Copy link
Member Author

cowboyd commented Apr 25, 2019

Microstates are already observable. Can this functionality be built on top of that?

I feel pretty strongly that this should be synchronous and built in to the core... an abstraction of the relationships that already exist.

As for the copyIntoEach example, that was perhaps a bad one. The main goal was to show how you could express arbitrary relationships in user-space well beyond those that come pre-packaged with microstates. So if you wanted to express classic relational data, you might be able to say:

class Blog {
  author = belongsTo(Person);
}

and the belongsTo function would be expressed in terms of the relationship function.

This proposal is just about the low-level relationship concept and not any higher level relationships (good or bad) that you might want to build with it.

@mharris717
Copy link
Contributor

I think my stumbling block is that I am treating the requirement to modify create as a smell. This is probably wrong, since the current set of functionality is not special.

Coming from Ruby, I can use "macros" to do things, building on primitives, because they are just functions acting on the class.

In Microstates, the = trips me up. Because that's only defining something with the DSL (correct me if wrong please), it implies a requirement for the Microstates create code to accommodate it. I get that you're saying create would support a small set of primitives, and these other macros would build on them, in the same way that I can write a custom validates_X macro in Ruby that calls validates under the hood. But I still can't shake the worry I feel when I see this approach.

@mharris717
Copy link
Contributor

Can observability/data events be leveraged by having the outer transition method be aware of a list of observers? A transition could do it's thing, then trigger all observers synchronously, potentially modifying the return based on what the observers return, and returning the result of that.

(possible nonsense)

@cowboyd
Copy link
Member Author

cowboyd commented Apr 25, 2019

Can observability/data events be leveraged by having the outer transition method be aware of a list of observers? A transition could do it's thing, then trigger all observers synchronously, potentially modifying the return based on what the observers return, and returning the result of that.

It could but then I think we'd lose some of the special sauce that makes microstates microstates. At the end of the day, the core of microstates is just a directed graph of typed values that know how to transition themselves, and so this proposal is about normalizing (and making extensible) how the edges of that graph are traversed.

The observability and events are layered on top in order to react to changes in that graph, but ultimately, the fact that the graph is just one big, single, lazy computation has served us very well thus far, and all the best changes in microstates have lead us toward that configuration, and not away from it.

@cowboyd
Copy link
Member Author

cowboyd commented Apr 25, 2019

I think my stumbling block is that I am treating the requirement to modify create as a smell. This is probably wrong, since the current set of functionality is not special.

The goal here is to make create() closed to modification after this point, which you've said you see. What would be an alternative to =?

@taras
Copy link
Member

taras commented Apr 25, 2019

In Microstates, the = trips me up.

This necessity for = is strictly artifact of the fact that we were trying to find a DSL that wouldn't require a custom Babel plugin that isn't commonly available. The assignment is a way to provide metadata that can be used to construct the Microstate.

This is by no means the best option... especially as compilers are now standard toolset for any project. TypeScript really shifts this further. With TypeScript and decorators, we can use reflect-metadata to capture metadata from TypeScript and store it in Microstates metadata to use for constructing the Microstate.

One of the advantages of relationship is that it's pretty close to how decorators work. With TypeScript as the DSL, our relationships could look like this.

class Human {
  @child name: String;
  @child pet: Dog;
}

// good
class Human {
  @child name: String;
  
  @relationship(({name}, value) => name.state === 'Bob' ? {Type: Dog, value} : {Type: Cat, value}) 
  pet: Dog | Cat
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants