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 diff --git a/package.json b/package.json index 7d1feb94..5370c499 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", diff --git a/src/identity.js b/src/identity.js index f76da43d..3e0ae6ef 100644 --- a/src/identity.js +++ b/src/identity.js @@ -1,96 +1,10 @@ -import { foldl } from 'funcadelic'; -import { promap, valueOf, pathOf, Meta, mount } from './meta'; -import { methodsOf } from './reflection'; -import { isArrayType } from './types/array'; -import { create } from './microstates'; +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 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 next = microstate[name](...args); - - return next === current ? identity : tick(next); - }; - 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/lens.js b/src/lens.js index a15679fd..e75e8afe 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); @@ -22,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)); } }); @@ -59,8 +60,8 @@ 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; +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; if (part === context[property]) { @@ -77,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/meta.js b/src/meta.js index 45e2b449..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; @@ -41,55 +38,21 @@ 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); -} - -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); + 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; } - } -}); - -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]); - } - }); - }, {}))); - } - } -}); + }), substate); +} diff --git a/src/pathmap.js b/src/pathmap.js new file mode 100644 index 00000000..6a565451 --- /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 new file mode 100644 index 00000000..7b630cff --- /dev/null +++ b/src/tree.js @@ -0,0 +1,27 @@ +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); + } else { + 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, defineChildren } = Tree.prototype; diff --git a/src/types/array.js b/src/types/array.js index 2d75b2f6..11fdb66d 100644 --- a/src/types/array.js +++ b/src/types/array.js @@ -1,19 +1,11 @@ -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'; - - -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,36 +86,43 @@ 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() { - 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); + + Tree.instance(this, { + childAt(key, array) { + if (typeof key === 'number') { + let value = valueOf(array)[key]; + return mount(array, create(T, value), key); } 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); } - }; - } - }; - } - })); + 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/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/identity.test.js b/tests/identity.test.js index 434f3ef7..7dd0e52d 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; + let first, second; beforeEach(function() { - let [ first ] = id.completed; - next = first.title.set('Take out the milk'); + [ 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] = 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; }); diff --git a/tests/pathmap.test.js b/tests/pathmap.test.js new file mode 100644 index 00000000..eaf9e439 --- /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 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; +} 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"];