From 6ecd657ffa88f425acf472834427e19cb5e31825 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 8 Feb 2019 12:11:04 -0700 Subject: [PATCH 01/10] Mke transitions in a store return latest node at transition path. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right now, a microstate created “in the wild” with the `create()` function always returns an instance of that microstate for all of its transitions and all of its child transitions. ```js create(class Counter { count = Number; }) .set({ count: 5 }) .count.increment() .count.increment(-3) ``` This makes sense within the context of a transition, where you’re always dealing with the root node of the thing being transitioned. Microstates inside a store follow this pattern currently, but I’m not so sure it’s a good idea. The reason is that most of the time, when you’re invoking a transition from a microstate referenced in a store, you: 1. are not invoking a chain, you’re invoking the outermost transition 2. you’re working with a “slice” of the tree, not the entire tree So this API isn’t as useful, and it could even be bad. The reason this occured to me is in the modelling of side-effects. For example, imagine you have a (contrived) microstate tree that looks like this: ```class App { router = class Router { avatars = class AvatarPage { uploader = Uploader { uploads = [Upload]; editor = Editor; }; }; } ``` and I want to have some effect-ful logic that operates just on the file uploads. For an example, see: https://github.com/cowboyd/microstates-file-upload/blob/master/src/uploader-controller.js#L63-L77) In microstates today, every transition returns the root of the tree. In other words, if this uploader were embedded into the `App` object like above, then the result of `upload.start()` would be an `App` object: ```js upload.start() //=> App {} ``` Not only is this not really useful, it’s potentially dangerous in that we’re effectively “leaking” the enclosing context into a sub-context. What if we wanted to take our App, and put it into a multi-tenant platform like: ```js class Platform { apps = [App]; } ``` If you had an upload that was somehow depending on the fact that `upload.start()` returned an `App` microstate, it would break when suddenly it now returns a `Platform` microstate. and we _always_ want to be able to embed microstates into other microstates without negative consequences. Pursuant to the discussion on slack, this implements option (1) where transitions always return the "freshest" version of the microstate at a given path. ```js upload //=> Upload.New {} upload.start() //=> Upload.Started {} ``` --- src/identity.js | 7 +++- tests/identity.test.js | 94 +++++++++++++++++++++--------------------- 2 files changed, 52 insertions(+), 49 deletions(-) diff --git a/src/identity.js b/src/identity.js index f76da43d..d38361e2 100644 --- a/src/identity.js +++ b/src/identity.js @@ -76,7 +76,12 @@ export default function Identity(microstate, observe = x => x) { }, current); let next = microstate[name](...args); - return next === current ? identity : tick(next); + if (next !== current) { + tick(next); + return this; + } else { + return view(Path(path), pathmap); + } }; return methods; }, {}, methods)); diff --git a/tests/identity.test.js b/tests/identity.test.js index 434f3ef7..d647af84 100644 --- a/tests/identity.test.js +++ b/tests/identity.test.js @@ -13,63 +13,63 @@ describe('Identity', () => { describe('complex type', () => { let id; let microstate; + let latest; beforeEach(function() { microstate = create(TodoMVC) .todos.push({ title: "Take out The Milk", completed: true }) .todos.push({ title: "Convince People Microstates is awesome" }) .todos.push({ title: "Take out the Trash" }) .todos.push({ title: "profit $$"}); - id = Identity(microstate); + latest = id = Identity(microstate, x => latest = x); }); - + it('is derived from its source object', function() { expect(id).toBeInstanceOf(TodoMVC); }); - + it('has the same shape as the initial state.', function() { expect(id.completeAll).toBeInstanceOf(Function); expect(id.todos).toHaveLength(4); - + let [ first ] = id.todos; let [ $first ] = microstate.todos; expect(first).toBeInstanceOf(Todo); expect(valueOf(first)).toBe(valueOf($first)); }); - + describe('invoking a transition', function() { - let next, third; + let third; beforeEach(function() { [ ,, third ] = id.todos; - - next = third.completed.set(true); + + third.completed.set(true); }); - + it('transitions the nodes which did change', function() { - expect(next).not.toBe(id); - expect(next.todos).not.toBe(id.todos); - let [ ,, $third] = next.todos; + expect(latest).not.toBe(id); + expect(latest.todos).not.toBe(id.todos); + let [ ,, $third] = latest.todos; expect($third).not.toBe(third); }); - + it('maintains the === identity of the nodes which did not change', function() { let [first, second, third, fourth] = id.todos; - let [$first, $second, $third, $fourth] = next.todos; + let [$first, $second, $third, $fourth] = latest.todos; expect($third.title).toBe(third.title); expect($first).toBe(first); expect($second).toBe(second); expect($fourth).toBe(fourth); }); }); - + describe('implicit method binding', function() { - let next; beforeEach(function() { let shift = id.todos.shift; - next = shift(); + shift(); }); - + it('still completes the transition', function() { - expect(valueOf(next)).toEqual({ + expect(valueOf(latest)).toEqual({ todos: [{ title: "Convince People Microstates is awesome" }, { @@ -80,38 +80,37 @@ describe('Identity', () => { }); }); }); - + describe('transition stability', function() { - let next; beforeEach(function() { let [ first ] = id.todos; - next = first.completed.set(false); + first.completed.set(false); }); - + it('uses the same function for each location in the graph, even for different instances', function() { - expect(next).not.toBe(id); - expect(next.set).toBe(id.set); - + expect(latest).not.toBe(id); + expect(latest.set).toBe(id.set); + let [ first ] = id.todos; - let [ $first ] = next.todos; - + let [ $first ] = latest.todos; + expect($first.push).toBe(first.push); expect($first.completed.toggle).toBe(first.completed.toggle); }); }); - + describe('the identity callback function', function() { let store; beforeEach(function() { store = Identity(microstate, () => undefined); }); - + it('ignores the return value of the callback function when determining the value of the store', function() { expect(store).toBeDefined(); expect(store).toBeInstanceOf(TodoMVC); }); }); - + describe('idempotency', function() { let calls; let store; @@ -124,47 +123,46 @@ describe('Identity', () => { let [ first ] = store.todos; first.completed.set(true); }); - + it('does not invoke the idenity function on initial invocation', function() { expect(calls).toEqual(0); }); }); - + describe('identity of queries', function() { it('traverses queries and includes the microstates within them', function() { expect(id.completed).toBeDefined(); let [ firstCompleted ] = id.completed; expect(firstCompleted).toBeInstanceOf(Todo); }); - + describe('the effect of transitions on query identities', () => { - let next; beforeEach(function() { let [ first ] = id.completed; - next = first.title.set('Take out the milk'); + first.title.set('Take out the milk'); }); - + it('updates those queries which contain changed objects, but not ids *within* the query that remained the same', () => { let [first, second] = id.completed; - let [$first, $second] = next.completed; - expect(next.completed).not.toBe(id.completed); + let [$first, $second] = latest.completed; + expect(latest.completed).not.toBe(id.completed); expect($first).not.toBe(first); expect($second).toBe(second); }); - + it.skip('maintains the === identity of those queries which did not change', function() { let [first, second] = id.active; - let [$first, $second] = next.active; + let [$first, $second] = latest.active; expect($first).toBe(first); expect($second).toBe(second); - expect(next.active).toBe(id.active); + expect(latest.active).toBe(id.active); }); - + it('maintains the === identity of the same node that appears at different spots in the tree', () => { let [ first ] = id.todos; let [ firstCompleted ] = id.completed; - let [ $first ] = next.todos; - let [ $firstCompleted ] = next.completed; + let [ $first ] = latest.todos; + let [ $firstCompleted ] = latest.completed; expect(first).toBe(firstCompleted); expect($first).toBe($firstCompleted); }); @@ -180,7 +178,7 @@ describe('Identity', () => { } it('it keeps both branches', () => { - let s = Identity(create(Person), next => (s = next)); + let s = Identity(create(Person), latest => (s = latest)); let { name, father, mother } = s; name.set('Bart'); @@ -202,7 +200,7 @@ describe('Identity', () => { describe('identity support for microstates created with from(object)', () => { let i; beforeEach(() => { - i = Identity(from({ name: 'Taras' }), next => i = next); + i = Identity(from({ name: 'Taras' }), latest => i = latest); i.name.concat('!!!'); }); it('allows to transition value of a property', () => { @@ -221,7 +219,7 @@ describe('Identity', () => { let i, setA; beforeEach(() => { - i = Identity(create(Child), next => i = next); + i = Identity(create(Child), latest => i = latest); setA = i.setA; }); From c3452f8dfa58d81aed3e329645ca9b876aeb72d6 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 12 Feb 2019 17:57:11 -0700 Subject: [PATCH 02/10] Remove useless `promap` Back when the microstate tree was eagerly evaluated, we had to whenever we mounted one tree onto another we had to map over the entire tree in memory and update the metadata associated with each node in order to adjust its path and so forth. But now that the tree is evaluated completely lazily, this type of mapping is no longer necessary. Instead it is enough to derive the mounted substate from the current state which is its parent, and all subsequent substates will do the same. Note that this is the first step towards removing the `Profunctor` class altogether. --- src/meta.js | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/src/meta.js b/src/meta.js index 45e2b449..0fc03b23 100644 --- a/src/meta.js +++ b/src/meta.js @@ -41,25 +41,23 @@ export function mount(microstate, substate, key) { let parent = view(Meta.data, microstate); let prefix = compose(parent.lens, At(key, parent.value)); - return promap(x => x, object => { - return over(Meta.data, meta => ({ - get root() { - return parent.root; - }, - get lens() { - return compose(prefix, meta.lens); - }, - get path() { - return parent.path.concat([key]).concat(meta.path); - }, - get value() { - return meta.value; - }, - get source() { - return meta.source; - } - }), object); - }, substate); + return over(Meta.data, meta => ({ + get root() { + return parent.root; + }, + get lens() { + return compose(prefix, meta.lens); + }, + get path() { + return parent.path.concat([key]).concat(meta.path); + }, + get value() { + return meta.value; + }, + get source() { + return meta.source; + } + }), substate); } export const Profunctor = type(class { From 7c94679d8d152a235767a99167407f265e8e845b Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 31 Jan 2019 16:17:00 -0700 Subject: [PATCH 03/10] Use abstract operation to access microstate children by key. An identity is a reference to a microstate that can change over time, but the actual Id object doesn't have a direct reference to the microstate it represents. Instead it holds the _path_ of the microstate and looks it up each time from the latest value that is contained in the identity. This process of looking up the corresponding microstate means navigating down each element of the path. But this presents a problem. Due to the laziness of Arrays (and soon Objects) We can't use standard keyed access all the time because array microstates can only access their children via enumeration. In other words, normal JS property access doesn't work. ```js let array = create([Number], [1,2,3]); array[2] //=> undefined let [one, two] = array; one //=> Microstate; ``` We got around this before by putting in a special hack for arrays to check "hey, is this an array microstate? If so, let's give a separate treatment." Not only was this cumbersome, but we'll need to add yet another hack for `ObjectType`, and furthermore, it is incompatible with the changes that we need to put in place to make identities support union types. This change adds a typeclass `Tree` with a single method `childAt` which abstracts away the JS operation for property access. That way, if we have the path of an entity, we can resolve it even if it's comprised of many different types of objects that may or may not support direct keyed access. As a result, the `At` lens now uses `childAt`, so that microstates can also be traversed, not just POJOs. However, only for the `view` operation. A corresponding polymorphic `setChildAt()` would be required to make a change. --- src/identity.js | 17 ++++------------- src/lens.js | 4 +++- src/tree.js | 13 +++++++++++++ src/types/array.js | 23 ++++++++++++++--------- 4 files changed, 34 insertions(+), 23 deletions(-) create mode 100644 src/tree.js diff --git a/src/identity.js b/src/identity.js index d38361e2..123c07c0 100644 --- a/src/identity.js +++ b/src/identity.js @@ -1,8 +1,6 @@ import { foldl } from 'funcadelic'; -import { promap, valueOf, pathOf, Meta, mount } from './meta'; +import { promap, valueOf, pathOf, Meta } from './meta'; import { methodsOf } from './reflection'; -import { isArrayType } from './types/array'; -import { create } from './microstates'; //function composition should probably not be part of lens :) import { At, view, Path, compose, set } from './lens'; @@ -65,22 +63,15 @@ export default function Identity(microstate, observe = x => x) { Object.assign(Id.prototype, foldl((methods, name) => { methods[name] = function(...args) { - let path = P; - let microstate = path.reduce((microstate, key) => { - if (isArrayType(microstate)) { - let value = valueOf(microstate)[key]; - return mount(microstate, create(microstate.constructor.T, value), key); - } else { - return microstate[key]; - } - }, current); + let microstate = view(Path(Id.path), current); + let next = microstate[name](...args); if (next !== current) { tick(next); return this; } else { - return view(Path(path), pathmap); + return view(Path(Id.path), pathmap); } }; return methods; diff --git a/src/lens.js b/src/lens.js index a15679fd..869e261e 100644 --- a/src/lens.js +++ b/src/lens.js @@ -1,5 +1,7 @@ import { Functor, map, Semigroup } from 'funcadelic'; +import { childAt } from './tree'; + class Box { static get of() { return (...args) => new this(...args); @@ -60,7 +62,7 @@ export function Lens(get, set) { export const transparent = Lens(x => x, y => y); export function At(property, container = {}) { - let get = context => context != null ? context[property] : undefined; + let get = context => context != null ? childAt(property, context) : undefined; let set = (part, whole) => { let context = whole == null ? (Array.isArray(container) ? [] : {}) : whole; if (part === context[property]) { diff --git a/src/tree.js b/src/tree.js new file mode 100644 index 00000000..c37c2d2e --- /dev/null +++ b/src/tree.js @@ -0,0 +1,13 @@ +import { type } from 'funcadelic'; + +export const Tree = type(class { + childAt(key, parent) { + if (parent[Tree.symbol]) { + return this(parent).childAt(key, parent); + } else { + return parent[key]; + } + } +}); + +export const { childAt } = Tree.prototype; diff --git a/src/types/array.js b/src/types/array.js index 2d75b2f6..97acddba 100644 --- a/src/types/array.js +++ b/src/types/array.js @@ -3,17 +3,10 @@ import { At, set } from '../lens'; import { Profunctor, promap, mount, valueOf } from '../meta'; import { create } from '../microstates'; import parameterized from '../parameterized'; - - -const ARRAY_TYPE = Symbol('ArrayType'); - -export function isArrayType(microstate) { - return microstate.constructor && microstate.constructor[ARRAY_TYPE]; -} +import { Tree, childAt } from '../tree'; export default parameterized(T => class ArrayType { static T = T; - static get [ARRAY_TYPE]() { return true; } static get name() { return `Array<${T.name}>`; @@ -94,13 +87,25 @@ export default parameterized(T => class ArrayType { let index = i++; return { get done() { return next.done; }, - get value() { return mount(array, create(T, next.value), index); } + get value() { return childAt(index, array); } }; } }; } static initialize() { + + Tree.instance(this, { + childAt(key, array) { + if (typeof key === 'number') { + let value = valueOf(array)[key]; + return mount(array, create(T, value), key); + } else { + return array[key]; + } + } + }); + Profunctor.instance(this, { promap(input, output, array) { let next = input(array); From cee8643f031aa429ed20e83d117fad98d5b398fa Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 13 Feb 2019 12:39:12 -0700 Subject: [PATCH 04/10] Base the identity store on a map of paths to stable references. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store implementation is currently based on a tree mapping algorithm that, though lazy, is theoretically a mapping of the entire microstate tree. This worked well when what we were doing with the store was walking the entire tree as part of a render() cycle that starts at the top and then enumerates all children. The problem with this is that it does not allow you to enter the graph at any point. This is pretty necessary when modelling side-effects, where you might want to be able to find out what the current stable reference is for a node that you might be working with over a long time. Originally modelled using This replaces the tree mapping strategy with a "pathmap" strategy. When a microstate is enumerated or accessed, a `Location` object is allocated for it at that path. So, for example the progress of the second upload of the uploader would have a path `['uploads', 1, 'progress']`. Once allocated, that location _never changes_, which allows us to look up a location directly if we have its path. The `Location` has all the information needed to work with that path: the current POJO value at that path, the microstate in the tree at that path, and the `reference` which is of the same type as the microstate that will be stable and proxy that microstate. While the `Location` never changes, the `reference` that it holds does change as the underlying value changes. This is what provides us with a stable, yet immutable structure. So, in our uploader example: ```js class Uploader { uploads = [class Upload {}] } ``` The location / reference structure would look like: ``` ┌───────────┐ ┌───────────┐ │ │ │ │ │ Location │───reference───────▶│ Uploader │ │ │ │ │ └──────┬────┘ └─────┬─────┘ │ │ │ │ │ │ ┌───────────┐ │ ┌───────────┐ │ uploads │ │ │uploads │ │ └─────────▶│ [Upload] │ └────────▶│ Location │──reference────────▶│ │ │ │ └───────────┘ └────┬──────┘ │ │ ┌────────┐ │ ┌───────────┐ │ │ │ │ 0 │ │ │─ ─ ─ ─ ─ ▶│ Upload │ ├──────────▶│ Location │───reference───────▶│ │ │ │ │ │ └────────┘ │ └───────────┘ │ │ │ ┌────────┐ │ ┌───────────┐ │ │ │ │ 1 │ │ ─ ─ ─ ─ ─ ▶│ Upload │ └──────────▶│ Location │───reference───────▶│ │ │ │ └────────┘ └───────────┘ ``` Notice how the array of Uploads `[Upload]` does not actually have keyed access to its children. Those are lazily enumerated. However, once they _are_ enumerated, then we can assign them a location based on the position they appeared in when they were enumerated. This allows us to leap right back to that position if we need to work with this element in the future (as in the case where we want to report progress on a specific upload as it happens). Notes: - I'm not thrilled about the `defineChildren` mechanism, but there has to be a way for these references to "intercept" children and replace them with further references. Specifically, this behavior must be different between Arrays and other container classes. I think this can be generalized in the future, but I think that there has to be some clarification on where the DSL officially hooks into the relationship definition process. I think - The `Profunctor` typeclass is now completely obsolete and should be removed in a follow-on change. - The storage class is just a very light-weight method for keeping a POJO, working with various paths within it, and receiving notifications of when it changes. --- src/identity.js | 94 ++----------------------------- src/pathmap.js | 89 +++++++++++++++++++++++++++++ src/storage.js | 28 ++++++++++ src/tree.js | 14 ++++- src/types/array.js | 21 +++++++ tests/identity.test.js | 4 +- tests/pathmap.test.js | 123 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 282 insertions(+), 91 deletions(-) create mode 100644 src/pathmap.js create mode 100644 src/storage.js create mode 100644 tests/pathmap.test.js diff --git a/src/identity.js b/src/identity.js index 123c07c0..3e0ae6ef 100644 --- a/src/identity.js +++ b/src/identity.js @@ -1,92 +1,10 @@ -import { foldl } from 'funcadelic'; -import { promap, valueOf, pathOf, Meta } from './meta'; -import { methodsOf } from './reflection'; +import { valueOf } from './meta'; -//function composition should probably not be part of lens :) -import { At, view, Path, compose, set } from './lens'; - -class Location { - static id = At(Symbol('@id')); -} +import Storage from './storage'; +import Pathmap from './pathmap'; export default function Identity(microstate, observe = x => x) { - let current; - let identity; - let pathmap = {}; - - function tick(next) { - update(next); - observe(identity); - return identity; - } - - function update(microstate) { - current = microstate; - return identity = promap(proxify, persist, microstate); - - function proxify(microstate) { - let path = pathOf(microstate); - let Type = microstate.constructor.Type; - let value = valueOf(microstate); - - let id = view(compose(Path(path), Location.id), pathmap); - - let Id = id != null && id.constructor.Type === Type ? id.constructor : IdType(Type, path); - return new Id(value); - } - - function persist(id) { - let location = compose(Path(id.constructor.path), Location.id); - let existing = view(location, pathmap); - if (!equals(id, existing)) { - pathmap = set(location, id, pathmap); - return id; - } else { - return existing; - } - } - } - - function IdType(Type, P) { - class Id extends Type { - static Type = Type; - static path = P; - static name = `Id<${Type.name}>`; - - constructor(value) { - super(value); - Object.defineProperty(this, Meta.symbol, { enumerable: false, configurable: true, value: new Meta(this, valueOf(value))}); - } - } - - let methods = Object.keys(methodsOf(Type)).concat(["set"]); - - Object.assign(Id.prototype, foldl((methods, name) => { - methods[name] = function(...args) { - let microstate = view(Path(Id.path), current); - - let next = microstate[name](...args); - - if (next !== current) { - tick(next); - return this; - } else { - return view(Path(Id.path), pathmap); - } - }; - return methods; - }, {}, methods)); - - return Id; - } - update(microstate); - return identity; -} - -function equals(id, other) { - if (other == null) { - return false; - } else { - return other.constructor.Type === id.constructor.Type && valueOf(id) === valueOf(other); - } + let { Type } = microstate.constructor; + let pathmap = Pathmap(Type, new Storage(valueOf(microstate), () => observe(pathmap.get()))); + return pathmap.get(); } diff --git a/src/pathmap.js b/src/pathmap.js new file mode 100644 index 00000000..2ab3f4e0 --- /dev/null +++ b/src/pathmap.js @@ -0,0 +1,89 @@ +import { methodsOf } from './reflection'; +import { create } from './microstates'; +import { view, Path } from './lens'; +import { valueOf, Meta } from './meta'; +import { defineChildren } from './tree'; +import { stable } from 'funcadelic'; + +import Storage from './storage'; +// TODO: explore compacting non-existent locations (from removed arrays and objects). + +export default function Pathmap(Root, ref) { + let paths = new Storage(); + + class Location { + + static symbol = Symbol('Location'); + + static allocate(path) { + let existing = paths.getPath(path.concat(Location.symbol)); + if (existing) { + return existing; + } else { + let location = new Location(path); + paths.setPath(path, { [Location.symbol]: location }); + return location; + } + } + + get currentValue() { + return view(this.lens, ref.get()); + } + + get reference() { + if (!this.currentReference || (this.currentValue !== valueOf(this.currentReference))) { + return this.currentReference = this.createReference(); + } else { + return this.currentReference; + } + } + + get microstate() { + return view(this.lens, create(Root, ref.get())); + } + + constructor(path = []) { + this.path = path; + this.lens = Path(path); + this.createReferenceType = stable(Type => { + let location = this; + let typeName = Type.name ? Type.name: 'Unknown'; + + class Reference extends Type { + static name = `Ref<${typeName}>`; + static location = location; + + constructor(value) { + super(value); + Object.defineProperty(this, Meta.symbol, { enumerable: false, configurable: true, value: new Meta(this, valueOf(value))}); + defineChildren(key => Location.allocate(path.concat(key)).reference, this); + } + } + + for (let methodName of Object.keys(methodsOf(Type)).concat("set")) { + Reference.prototype[methodName] = (...args) => { + let microstate = location.microstate; + let next = microstate[methodName](...args); + ref.set(valueOf(next)); + return location.reference; + }; + } + + return Reference; + }); + } + + createReference() { + let { Type } = this.microstate.constructor; + let Reference = this.createReferenceType(Type); + + return new Reference(this.currentValue); + } + + get(reference = paths.getPath([Location.symbol, 'reference'])) { + return reference.constructor.location.reference; + } + } + + return Location.allocate([]); +} diff --git a/src/storage.js b/src/storage.js new file mode 100644 index 00000000..980b152f --- /dev/null +++ b/src/storage.js @@ -0,0 +1,28 @@ +import { view, set, Path } from './lens'; + +export default class Storage { + constructor(value, observe = x => x) { + this.value = value; + this.observe = observe; + } + + get() { + return this.value; + } + + set(value) { + if (value !== this.value) { + this.value = value; + this.observe(); + } + return this; + } + + getPath(path) { + return view(Path(path), this.value); + } + + setPath(path, value) { + return this.set(set(Path(path), value, this.value)); + } +} diff --git a/src/tree.js b/src/tree.js index c37c2d2e..1cd7387f 100644 --- a/src/tree.js +++ b/src/tree.js @@ -1,5 +1,7 @@ import { type } from 'funcadelic'; +import CachedProperty from './cached-property'; + export const Tree = type(class { childAt(key, parent) { if (parent[Tree.symbol]) { @@ -8,6 +10,16 @@ export const Tree = type(class { return parent[key]; } } + + defineChildren(fn, parent) { + if (parent[Tree.symbol]) { + return this(parent).defineChildren(fn, parent); + } else { + for (let property of Object.keys(parent)) { + Object.defineProperty(parent, property, CachedProperty(property, () => fn(property, parent))); + } + } + } }); -export const { childAt } = Tree.prototype; +export const { childAt, defineChildren } = Tree.prototype; diff --git a/src/types/array.js b/src/types/array.js index 97acddba..997e7036 100644 --- a/src/types/array.js +++ b/src/types/array.js @@ -103,6 +103,27 @@ export default parameterized(T => class ArrayType { } else { return array[key]; } + }, + + defineChildren(fn, array) { + let generate = array[Symbol.iterator]; + return Object.defineProperty(array, Symbol.iterator, { + enumerable: false, + value() { + let iterator = generate.call(array); + let i = 0; + return { + next() { + let next = iterator.next(); + let index = i++; + return { + get done() { return next.done; }, + get value() { return fn(index, next.value, array); } + }; + } + }; + } + }); } }); diff --git a/tests/identity.test.js b/tests/identity.test.js index d647af84..7dd0e52d 100644 --- a/tests/identity.test.js +++ b/tests/identity.test.js @@ -137,13 +137,13 @@ describe('Identity', () => { }); describe('the effect of transitions on query identities', () => { + let first, second; beforeEach(function() { - let [ first ] = id.completed; + [ first, second ] = id.completed; first.title.set('Take out the milk'); }); it('updates those queries which contain changed objects, but not ids *within* the query that remained the same', () => { - let [first, second] = id.completed; let [$first, $second] = latest.completed; expect(latest.completed).not.toBe(id.completed); expect($first).not.toBe(first); diff --git a/tests/pathmap.test.js b/tests/pathmap.test.js new file mode 100644 index 00000000..88b5cd50 --- /dev/null +++ b/tests/pathmap.test.js @@ -0,0 +1,123 @@ +/* global describe, beforeEach, it */ + +import expect from 'expect'; + +import Pathmap from '../src/pathmap'; +import { valueOf, BooleanType, ArrayType, NumberType } from '../index'; + +describe('pathmap', ()=> { + + let pathmap; + let LightSwitch; + let ref; + let id; + let hall; + let closet; + + let current; + beforeEach(()=> { + ref = Ref({}); + LightSwitch = class LightSwitch { + hall = Boolean; + closet = Boolean; + }; + pathmap = Pathmap(LightSwitch, ref); + id = pathmap.get(); + hall = id.hall; + closet = id.closet; + + current = pathmap.get; + }); + + it('exists', () => { + expect(pathmap).toBeDefined(); + }); + it('has an id delegate which is represents the microstate at the base path', ()=> { + expect(id).toBeInstanceOf(LightSwitch); + expect(valueOf(id)).toBe(ref.get()); + }); + + it('has children corresponding to the shit to the substates', ()=> { + expect(id.hall).toBeInstanceOf(BooleanType); + expect(id.closet).toBeInstanceOf(BooleanType); + }); + + describe('transitioning a substate', ()=> { + beforeEach(()=> { + id.hall.set(true); + }); + it('updates the reference', ()=> { + expect(ref.get()).toEqual({ + hall: true + }); + }); + it('changes the object representing the reference to the toggled switch', ()=> { + expect(pathmap.get(id).hall).not.toBe(hall); + }); + it('changes the root reference if you fetch it again from the pathmap', ()=> { + expect(pathmap.get()).not.toBe(id); + }); + it('leaves the object representing the un-touched switch to be the same', ()=> { + expect(id.closet).toBe(closet); + }); + + it('can fetch the current value based off of the old one.', ()=> { + expect(current(hall)).toBe(current(id).hall); + expect(current(id)).toBe(pathmap.get()); + }); + + it('keeps all the methods stable at each location.', ()=> { + expect(hall.set).toBe(id.hall.set); + }); + }); + + describe('working with arrays', ()=> { + beforeEach(()=> { + pathmap = Pathmap(ArrayType.of(Number), Ref([1, 2, 3])); + id = pathmap.get(); + }); + it('creates a proxy object for all of its children', ()=> { + let [ one, two, three ] = id; + expect(one).toBeInstanceOf(NumberType); + expect(one.constructor.name).toBe('Ref'); + expect(two).toBeInstanceOf(NumberType); + expect(two.constructor.name).toBe('Ref'); + expect(three).toBeInstanceOf(NumberType); + expect(three.constructor.name).toBe('Ref'); + }); + + describe('transitioning one of the contents', ()=> { + let first, second, third; + beforeEach(()=> { + let [one, two, three] = id; + first = one; + second = two; + third = three; + two.increment(); + }); + it('changes the root id', ()=> { + expect(current(id)).not.toBe(id); + }); + it('changes the array member that changed.', ()=> { + expect(current(second)).not.toBe(second); + }); + it('leaves the remaining children that did not change alone', ()=> { + expect(current(first)).toBe(first); + expect(current(third)).toBe(third); + }); + }); + + }); + +}); + +function Ref(value) { + let ref = { + get() { return value; }, + set(newValue) { + value = newValue; + return ref; + } + }; + return ref; +} From 1ab36d74488633b06452abde7e2d2eef664d54be Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 13 Feb 2019 14:52:01 -0700 Subject: [PATCH 05/10] Remove incidental profanity. --- tests/pathmap.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/pathmap.test.js b/tests/pathmap.test.js index 88b5cd50..eaf9e439 100644 --- a/tests/pathmap.test.js +++ b/tests/pathmap.test.js @@ -37,7 +37,7 @@ describe('pathmap', ()=> { expect(valueOf(id)).toBe(ref.get()); }); - it('has children corresponding to the shit to the substates', ()=> { + it('has children corresponding to the substates', ()=> { expect(id.hall).toBeInstanceOf(BooleanType); expect(id.closet).toBeInstanceOf(BooleanType); }); From e21b021382b55b856c333fe634259f8e2327352c Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 13 Feb 2019 22:16:33 -0700 Subject: [PATCH 06/10] Remove Profunctor class entirely. I had planned to remove this on a different branch, but coveralls is (correctly) complaining about all this dead code. This goes ahead and removes it on this branch so that the coverage numbers aren't thrown off. In the mean time, this removes some obsolete static symbols for metadata that is no longer stored. --- src/meta.js | 35 ----------------------------------- src/types/array.js | 29 +---------------------------- 2 files changed, 1 insertion(+), 63 deletions(-) diff --git a/src/meta.js b/src/meta.js index 0fc03b23..805d0f50 100644 --- a/src/meta.js +++ b/src/meta.js @@ -1,15 +1,12 @@ -import { type, append } from 'funcadelic'; import { At, compose, transparent, over, view } from './lens'; export class Meta { static symbol = Symbol('Meta'); static data = At(Meta.symbol); - static context = compose(Meta.data, At('context')); static lens = compose(Meta.data, At('lens')); static path = compose(Meta.data, At('path')); static value = compose(Meta.data, At('value')); static source = compose(Meta.data, At('source')); - static Type = compose(Meta.data, At('Type')); constructor(object, value) { this.root = object; @@ -59,35 +56,3 @@ export function mount(microstate, substate, key) { } }), substate); } - -export const Profunctor = type(class { - static name = 'Profunctor'; - - promap(input, output, object) { - if (metaOf(object) == null) { - return object; - } else { - return this(object).promap(input, output, object); - } - } -}); - -export const { promap } = Profunctor.prototype; - -Profunctor.instance(Object, { - promap(input, output, object) { - let next = input(object); - let keys = Object.keys(object); - if (next === object || keys.length === 0) { - return output(next); - } else { - return output(append(next, keys.reduce((properties, key) => { - return append(properties, { - get [key]() { - return promap(input, output, object[key]); - } - }); - }, {}))); - } - } -}); diff --git a/src/types/array.js b/src/types/array.js index 997e7036..11fdb66d 100644 --- a/src/types/array.js +++ b/src/types/array.js @@ -1,6 +1,5 @@ -import { append } from 'funcadelic'; import { At, set } from '../lens'; -import { Profunctor, promap, mount, valueOf } from '../meta'; +import { mount, valueOf } from '../meta'; import { create } from '../microstates'; import parameterized from '../parameterized'; import { Tree, childAt } from '../tree'; @@ -126,31 +125,5 @@ export default parameterized(T => class ArrayType { }); } }); - - Profunctor.instance(this, { - promap(input, output, array) { - let next = input(array); - let value = valueOf(array); - let length = value.length; - if (length === 0) { - return output(next); - } else { - return output(append(next, { - [Symbol.iterator]() { - let iterator = array[Symbol.iterator](); - return { - next() { - let next = iterator.next(); - return { - get done() { return next.done; }, - get value() { return promap(input, output, next.value); } - }; - } - }; - } - })); - } - } - }); } }); From 241bd36a0ab3361d8db7c8587e2f44a47f63649c Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 13 Feb 2019 22:40:58 -0700 Subject: [PATCH 07/10] Slightly improve coverage. nyc seems to not like default parameter values, and I'm not sure they're a good idea anyway. --- src/lens.js | 7 +++---- src/pathmap.js | 2 +- src/types/string.js | 6 +++++- tests/types/array.test.js | 12 ++++++++++++ 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/lens.js b/src/lens.js index 869e261e..e75e8afe 100644 --- a/src/lens.js +++ b/src/lens.js @@ -24,8 +24,7 @@ class Const extends Box {} Functor.instance(Id, { map(fn, id) { - let next = fn(id.value); - return next === id.value ? id : Id.of(next); + return Id.of(fn(id.value)); } }); @@ -61,7 +60,7 @@ export function Lens(get, set) { export const transparent = Lens(x => x, y => y); -export function At(property, container = {}) { +export function At(property, container) { let get = context => context != null ? childAt(property, context) : undefined; let set = (part, whole) => { let context = whole == null ? (Array.isArray(container) ? [] : {}) : whole; @@ -79,6 +78,6 @@ export function At(property, container = {}) { return Lens(get, set); } -export function Path(path = []) { +export function Path(path) { return path.reduce((lens, key) => compose(lens, At(key)), transparent); } diff --git a/src/pathmap.js b/src/pathmap.js index 2ab3f4e0..6a565451 100644 --- a/src/pathmap.js +++ b/src/pathmap.js @@ -42,7 +42,7 @@ export default function Pathmap(Root, ref) { return view(this.lens, create(Root, ref.get())); } - constructor(path = []) { + constructor(path) { this.path = path; this.lens = Path(path); this.createReferenceType = stable(Type => { diff --git a/src/types/string.js b/src/types/string.js index f566c9c6..0a547b64 100644 --- a/src/types/string.js +++ b/src/types/string.js @@ -4,7 +4,11 @@ export default class StringType extends Primitive { static name = "String"; initialize(value) { - return String(value == null ? '' : value); + if (value == null) { + return ''; + } else { + return String(value); + } } concat(value) { diff --git a/tests/types/array.test.js b/tests/types/array.test.js index 57f563c7..9103744d 100644 --- a/tests/types/array.test.js +++ b/tests/types/array.test.js @@ -6,6 +6,18 @@ import { create } from '../../src/microstates'; import { valueOf } from '../../src/meta'; describe("ArrayType", function() { + + describe("created with a scalar value", () => { + let ms; + beforeEach(()=> { + ms = create(ArrayType.of(Number), 1); + }); + + it('wraps the scalar value in an array', ()=> { + expect(valueOf(ms)).toEqual([1]); + }); + }); + describe("when unparameterized", function() { let ms; let array = ["a", "b", "c"]; From 36b8d63e2ae9f870bd9982a1672c6eba37a20975 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 14 Feb 2019 09:23:29 -0700 Subject: [PATCH 08/10] Give new `Tree` typeclass an explicit name. Uglify erases class names, which can break builds that use it. We ran into this issue before with the `Profunctor` typeclass. This adds an explicit name for the `Tree` typeclass so that we don't run into the same issue. --- src/tree.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tree.js b/src/tree.js index 1cd7387f..7b630cff 100644 --- a/src/tree.js +++ b/src/tree.js @@ -3,6 +3,8 @@ import { type } from 'funcadelic'; import CachedProperty from './cached-property'; export const Tree = type(class { + static name = 'Tree'; + childAt(key, parent) { if (parent[Tree.symbol]) { return this(parent).childAt(key, parent); From 67f8cf6833827d6d5caf17e8f5452dcff5e27304 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 14 Feb 2019 13:06:16 -0700 Subject: [PATCH 09/10] release 0.13.0 --- CHANGELOG.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6945506..0587cfe5 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,19 +6,40 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +## [0.13.0] - 2019-02-14 + +### Changed + +- BREAKING: Transitions initiated from a microstate reference in the + store, now return the new reference to the *same + microstate*. Before, they always returned the root of the microstate + tree, which made modelling side-effects difficult on deep microstate trees. +- Upgraded Rollup and associated plugins to latest version (via + greenkeeper) #306, #307, #309, #310 +- Fix up typos in README #308 (thanks @leonardodino) +- Improved code coverage in unit tests#320 +- Remove several pieces of dead code that weren't serving any purpose + #314, #318 + +### Fixed + +- Passing a microstate to `Array#push` and `Array#unshift` allowed + that microstate to become part of `valueOf`. Now, array unwraps all + arguments before performing any operations. + ## [0.12.4] - 2018-12-12 ### Fixed - Add explicit Profunctor class name to prevent the class name from being stripped by Uglifyjs https://github.com/microstates/microstates.js/pull/303 -### Fixed +### Changed - Gather all transitions from the prototype chain https://github.com/microstates/microstates.js/pull/290 ## [0.12.3] - 2018-12-12 -### Fixed +### Changed - Gather all transitions from the prototype chain https://github.com/microstates/microstates.js/pull/290 From 5f625a791c0f81499a9f3f5c52622e61fa679a42 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 14 Feb 2019 14:28:39 -0700 Subject: [PATCH 10/10] v0.13.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 94097789..f81ff54c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "microstates", - "version": "0.12.4", + "version": "0.13.0", "description": "Composable State Primitives for JavaScript", "keywords": [ "lens",