From 8546e93e5d143412f2a3e87cf2e2aefcae86cc44 Mon Sep 17 00:00:00 2001 From: Tony Garnock-Jones Date: Tue, 10 May 2016 00:40:53 -0400 Subject: [PATCH] Implement Syndicate/js broker-client and chat app. Support capturing with a pattern in the Syndicate/js DSL: ($foo = bar()) Struct has been cleaned up, and now offers proper Javascript objects for its prefab-like structures. These can serialize and deserialize themselves to/from JSON. They behave like prefabs in that two StructureTypes created with the same label and arity behave identically wrt Dataspaces and Tries. Sadly, prefab field names had to go in order to support this. Facets now track and terminate their children upon termination. This is experimental; I suspect it is required for nested durings. DemandMatcher can now support multiple specs, but this is less useful than you might think since it tracks supply and demand quite naively. It would have to have (surprise, surprise!) a mux-like structure to do the job properly! Added WakeDetector to main.js; adding the broker client will have to wait until it is turned into a proper module in the src/ directory. --- js/compiler/compiler.js | 35 ++++- js/examples/chat/broker-client.js | 237 ++++++++++++++++++++++++++++++ js/examples/chat/codec.js | 41 ++++++ js/examples/chat/index.html | 55 +++++++ js/examples/chat/index.js | 116 +++++++++++++++ js/examples/chat/style.css | 97 ++++++++++++ js/examples/jquery/index.js | 2 +- js/examples/smoketest/index.js | 4 +- js/examples/textfield/index.js | 10 +- js/src/ack.js | 4 +- js/src/actor.js | 40 ++++- js/src/dataspace.js | 87 +++++------ js/src/demand-matcher.js | 124 +++++++++++----- js/src/dom-driver.js | 11 +- js/src/jquery-driver.js | 6 +- js/src/main.js | 3 +- js/src/mux.js | 8 +- js/src/patch.js | 37 ++++- js/src/struct.js | 123 +++++++++++----- js/src/trie.js | 151 +++++++++++++------ js/src/wake-detector-driver.js | 47 ++++++ js/test/test-patch.js | 22 +-- js/test/test-route.js | 119 +++++++-------- js/test/test-syndicate.js | 4 +- 24 files changed, 1112 insertions(+), 271 deletions(-) create mode 100644 js/examples/chat/broker-client.js create mode 100644 js/examples/chat/codec.js create mode 100644 js/examples/chat/index.html create mode 100644 js/examples/chat/index.js create mode 100644 js/examples/chat/style.css create mode 100644 js/src/wake-detector-driver.js diff --git a/js/compiler/compiler.js b/js/compiler/compiler.js index e1f742d..0ce2aae 100644 --- a/js/compiler/compiler.js +++ b/js/compiler/compiler.js @@ -122,7 +122,7 @@ var modifiedSourceActions = { var label = maybeLabel.numChildren === 1 ? maybeLabel.children[0].interval.contents : JSON.stringify(typeName.interval.contents); - return 'var ' + typeName.asES5 + ' = Syndicate.Struct.makeStructureConstructor(' + + return 'var ' + typeName.asES5 + ' = Syndicate.Struct.makeConstructor(' + label + ', ' + JSON.stringify(formals) + ');'; }, @@ -293,14 +293,35 @@ semantics.addOperation('buildSubscription(acc,mode)', { v.children[0].buildSubscription(this.args.acc, this.args.mode); // both branches! }, + AssignmentExpression_assignment: function (lhsExpr, _assignmentOperator, rhsExpr) { + var i = lhsExpr.interval.contents; + if (i[0] === '$' && i.length > 1) { + switch (this.args.mode) { + case 'pattern': rhsExpr.buildSubscription(this.args.acc, this.args.mode); break; + case 'instantiated': lhsExpr.buildSubscription(this.args.acc, this.args.mode); break; + case 'projection': { + this.args.acc.push('(Syndicate._$(' + JSON.stringify(i.slice(1)) + ','); + rhsExpr.buildSubscription(this.args.acc, this.args.mode); + this.args.acc.push('))'); + break; + } + default: throw new Error('Unexpected buildSubscription mode ' + this.args.mode); + } + } else { + lhsExpr.buildSubscription(this.args.acc, this.args.mode); + _assignmentOperator.buildSubscription(this.args.acc, this.args.mode); + rhsExpr.buildSubscription(this.args.acc, this.args.mode); + } + }, + identifier: function(_name) { var i = this.interval.contents; - if (i[0] === '$') { + if (i[0] === '$' && i.length > 1) { switch (this.args.mode) { - case 'pattern': this.args.acc.push('_'); break; - case 'instantiated': this.args.acc.push(i.slice(1)); break; - case 'projection': this.args.acc.push('(Syndicate._$(' + JSON.stringify(i.slice(1)) + '))'); break; - default: throw new Error('Unexpected buildSubscription mode ' + this.args.mode); + case 'pattern': this.args.acc.push('_'); break; + case 'instantiated': this.args.acc.push(i.slice(1)); break; + case 'projection': this.args.acc.push('(Syndicate._$(' + JSON.stringify(i.slice(1)) + '))'); break; + default: throw new Error('Unexpected buildSubscription mode ' + this.args.mode); } } else { this.args.acc.push(i); @@ -328,7 +349,7 @@ semantics.addAttribute('bindings', { semantics.addOperation('pushBindings(accumulator)', { identifier: function(_name) { var i = this.interval.contents; - if (i[0] === '$') { + if (i[0] === '$' && i.length > 1) { this.args.accumulator.push(i.slice(1)); } }, diff --git a/js/examples/chat/broker-client.js b/js/examples/chat/broker-client.js new file mode 100644 index 0000000..eafd665 --- /dev/null +++ b/js/examples/chat/broker-client.js @@ -0,0 +1,237 @@ +"use strict"; +// WebSocket-based Syndicate broker client + +var Trie = Syndicate.Trie; +var Patch = Syndicate.Patch; +var Dataspace = Syndicate.Dataspace; +var Struct = Syndicate.Struct; +var DemandMatcher = Syndicate.DemandMatcher; +var __ = Syndicate.__; +var _$ = Syndicate._$; + +var DEFAULT_RECONNECT_DELAY = 100; // ms +var MAX_RECONNECT_DELAY = 30000; // ms +var DEFAULT_IDLE_TIMEOUT = 300000; // ms; i.e., 5 minutes +var DEFAULT_PING_INTERVAL = DEFAULT_IDLE_TIMEOUT - 10000; // ms + +var toBroker = Struct.makeConstructor('toBroker', ['url', 'assertion']); +var fromBroker = Struct.makeConstructor('fromBroker', ['url', 'assertion']); +var brokerConnection = Struct.makeConstructor('brokerConnection', ['url']); +var brokerConnected = Struct.makeConstructor('brokerConnected', ['url']); +var forceBrokerDisconnect = Struct.makeConstructor('forceBrokerDisconnect', ['url']); + +function spawnBrokerClientDriver() { + var URL = _$('url'); // capture used to extract URL + Dataspace.spawn( + new Dataspace(function () { + Dataspace.spawn( + new DemandMatcher([brokerConnection(URL)], + [brokerConnection(URL)], + { + demandMetaLevel: 1, + supplyMetaLevel: 0, + onDemandIncrease: function (c) { + Dataspace.spawn(new BrokerClientConnection(c.url)); + } + })); + })); +} + +function BrokerClientConnection(wsurl) { + this.wsurl = wsurl; + this.sock = null; + + this.sendsAttempted = 0; + this.sendsTransmitted = 0; + this.receiveCount = 0; + this.connectionCount = 0; + + this.reconnectDelay = DEFAULT_RECONNECT_DELAY; + this.idleTimeout = DEFAULT_IDLE_TIMEOUT; + this.pingInterval = DEFAULT_PING_INTERVAL; + + this.localAssertions = Trie.emptyTrie; + this.remoteAssertions = Trie.emptyTrie; + + this.activityTimestamp = 0; + this.idleTimer = null; + this.pingTimer = null; +} + +BrokerClientConnection.prototype.clearHeartbeatTimers = function () { + if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null; } + if (this.pingTimer) { clearTimeout(this.pingTimer); this.pingTimer = null; } +}; + +BrokerClientConnection.prototype.recordActivity = function () { + var self = this; + this.activityTimestamp = +(new Date()); + this.clearHeartbeatTimers(); + this.idleTimer = setTimeout(function () { self.forceclose(); }, this.idleTimeout); + this.pingTimer = setTimeout(function () { self.safeSend(JSON.stringify("ping")) }, + this.pingInterval); +}; + +BrokerClientConnection.prototype.boot = function () { + this.reconnect(); + var initialAssertions = + Patch.sub(toBroker(this.wsurl, __), 1) // read assertions to go out + .andThen(Patch.sub(Patch.observe(fromBroker(this.wsurl, __)), 1)) // and monitor interests + .andThen(Patch.assert(brokerConnection(this.wsurl))) // signal to DemandMatcher that we exist + .andThen(Patch.sub(brokerConnection(this.wsurl), 1)) // track demand + .andThen(Patch.sub(forceBrokerDisconnect(this.wsurl), 1)) + ; + return initialAssertions; +}; + +BrokerClientConnection.prototype.trapexit = function () { + this.forceclose(); +}; + +BrokerClientConnection.prototype.isConnected = function () { + return this.sock && this.sock.readyState === this.sock.OPEN; +}; + +BrokerClientConnection.prototype.safeSend = function (m) { + // console.log('safeSend', m); + try { + this.sendsAttempted++; + if (this.isConnected()) { + this.sock.send(m); + this.sendsTransmitted++; + } + } catch (e) { + console.warn("Trapped exn while sending", e); + } +}; + +BrokerClientConnection.prototype.sendPatch = function (p) { + var j = JSON.stringify(Codec.encodeEvent(Syndicate.stateChange(p))); + this.safeSend(j); +}; + +BrokerClientConnection.prototype.handleEvent = function (e) { + // console.log("BrokerClientConnection.handleEvent", e); + switch (e.type) { + case "stateChange": + if (e.patch.project(Patch.atMeta(brokerConnection(_$))).hasRemoved()) { + // console.log("Client is no longer interested in this connection", this.wsurl); + Dataspace.exit(); + } + + var pTo = e.patch.project(Patch.atMeta(toBroker(__, _$))); + + var pObsFrom = e.patch.project(Patch.atMeta(Patch.observe(fromBroker(__, _$)))); + pObsFrom = new Patch.Patch( + Trie.compilePattern(true, Patch.observe(Trie.embeddedTrie(pObsFrom.added))), + Trie.compilePattern(true, Patch.observe(Trie.embeddedTrie(pObsFrom.removed)))); + + var newLocalAssertions = this.localAssertions; + newLocalAssertions = pTo.label(Immutable.Set.of("to")).applyTo(newLocalAssertions); + newLocalAssertions = pObsFrom.label(Immutable.Set.of("obsFrom")).applyTo(newLocalAssertions); + + var trueSet = Immutable.Set.of(true); + var alwaysTrueSet = function (v) { return trueSet; }; + var p = Patch.computePatch(Trie.relabel(this.localAssertions, alwaysTrueSet), + Trie.relabel(newLocalAssertions, alwaysTrueSet)); + + this.localAssertions = newLocalAssertions; + // console.log("localAssertions"); + // console.log(Trie.prettyTrie(this.localAssertions)); + // console.log(p.pretty()); + this.sendPatch(p); + break; + + case "message": + var m = e.message; + if (Patch.atMeta.isClassOf(m)) { + m = m[0]; + if (toBroker.isClassOf(m)) { + var j = JSON.stringify(Codec.encodeEvent(Syndicate.message(m[1]))); + this.safeSend(j); + } else if (forceBrokerDisconnect.isClassOf(m)) { + this.forceclose(); + } + } + break; + } +}; + +BrokerClientConnection.prototype.forceclose = function (keepReconnectDelay) { + if (!keepReconnectDelay) { + this.reconnectDelay = DEFAULT_RECONNECT_DELAY; + } + this.clearHeartbeatTimers(); + if (this.sock) { + console.log("BrokerClientConnection.forceclose called"); + this.sock.close(); + this.sock = null; + } +}; + +BrokerClientConnection.prototype.reconnect = function () { + var self = this; + this.forceclose(true); + this.connectionCount++; + this.sock = new WebSocket(this.wsurl); + this.sock.onopen = Dataspace.wrap(function (e) { return self.onopen(e); }); + this.sock.onmessage = Dataspace.wrap(function (e) { + self.receiveCount++; + return self.onmessage(e); + }); + this.sock.onclose = Dataspace.wrap(function (e) { return self.onclose(e); }); +}; + +BrokerClientConnection.prototype.onopen = function (e) { + console.log("connected to " + this.sock.url); + this.recordActivity(); + Dataspace.stateChange(Patch.assert(brokerConnected(this.wsurl), 1)); + this.reconnectDelay = DEFAULT_RECONNECT_DELAY; + this.sendPatch((new Patch.Patch(this.localAssertions, Trie.emptyTrie)).strip()); +}; + +BrokerClientConnection.prototype.onmessage = function (wse) { + // console.log("onmessage", wse); + this.recordActivity(); + + var j = JSON.parse(wse.data, Struct.reviver); + if (j === "ping") { + this.safeSend(JSON.stringify("pong")); + return; + } else if (j === "pong") { + return; // recordActivity already took care of our timers + } + + var e = Codec.decodeAction(j); + switch (e.type) { + case "stateChange": { + var added = fromBroker(this.wsurl, Trie.embeddedTrie(e.patch.added)); + var removed = fromBroker(this.wsurl, Trie.embeddedTrie(e.patch.removed)); + var p = Patch.assert(added, 1).andThen(Patch.retract(removed, 1)); + // console.log('incoming stateChange'); + // console.log(p.pretty()); + Dataspace.stateChange(p); + break; + } + case "message": { + Dataspace.send(fromBroker(this.wsurl, e.message), 1); + break; + } + } +}; + +BrokerClientConnection.prototype.onclose = function (e) { + var self = this; + + // console.log("onclose", e); + Dataspace.stateChange(Patch.retract(brokerConnected(this.wsurl), 1)); + + console.log("reconnecting to " + this.wsurl + " in " + this.reconnectDelay + "ms"); + setTimeout(Dataspace.wrap(function () { self.reconnect(); }), this.reconnectDelay); + + this.reconnectDelay = this.reconnectDelay * 1.618 + (Math.random() * 1000); + this.reconnectDelay = + this.reconnectDelay > MAX_RECONNECT_DELAY + ? MAX_RECONNECT_DELAY + (Math.random() * 1000) + : this.reconnectDelay; +}; diff --git a/js/examples/chat/codec.js b/js/examples/chat/codec.js new file mode 100644 index 0000000..43489ec --- /dev/null +++ b/js/examples/chat/codec.js @@ -0,0 +1,41 @@ +"use strict"; +// Wire protocol representation of events and actions + +var Trie = Syndicate.Trie; +var Struct = Syndicate.Struct; + +function _encode(e) { + switch (e.type) { + case "stateChange": + return ["patch", e.patch.toJSON()]; + case "message": + return ["message", e.message]; + } +} + +function _decode(what) { + return function (j) { + switch (j[0]) { + case "patch": + return Syndicate.stateChange(Patch.fromJSON(j[1])); + case "message": + return Syndicate.message(j[1]); + default: + throw new Error("Invalid JSON-encoded " + what + ": " + JSON.stringify(j)); + } + }; +} + +/////////////////////////////////////////////////////////////////////////// + +// module.exports.encodeEvent = _encode; +// module.exports.decodeEvent = _decode("event"); +// module.exports.encodeAction = _encode; +// module.exports.decodeAction = _decode("action"); + +var Codec = { + encodeEvent: _encode, + decodeEvent: _decode("event"), + encodeAction: _encode, + decodeAction: _decode("action") +}; diff --git a/js/examples/chat/index.html b/js/examples/chat/index.html new file mode 100644 index 0000000..f8632af --- /dev/null +++ b/js/examples/chat/index.html @@ -0,0 +1,55 @@ + + + + Syndicate: Chat + + + + + + + + + + + + +
+
+
+ + + + + + + + +
+
+
+ +
+
+

Messages

+
+
+
+

Active Users

+
    +
    +
    + +
    +
    +
    + + +
    +
    +
    + +
    +
    
    +  
    +
    diff --git a/js/examples/chat/index.js b/js/examples/chat/index.js
    new file mode 100644
    index 0000000..0c95747
    --- /dev/null
    +++ b/js/examples/chat/index.js
    @@ -0,0 +1,116 @@
    +assertion type present(name, status);
    +assertion type says(who, message);
    +
    +var DOM = Syndicate.DOM.DOM;
    +var jQueryEvent = Syndicate.JQuery.jQueryEvent;
    +
    +///////////////////////////////////////////////////////////////////////////
    +// Application
    +
    +function spawnChatApp() {
    +  $("#chat_form").submit(function (e) { e.preventDefault(); return false; });
    +  $("#nym_form").submit(function (e) { e.preventDefault(); return false; });
    +  if (!($("#nym").val())) { $("#nym").val("nym" + Math.floor(Math.random() * 65536)); }
    +
    +  actor {
    +    forever {
    +      on asserted jQueryInput('#nym',    $v) { this.nym = v; }
    +      on asserted jQueryInput('#status', $v) { this.status = v; }
    +
    +      on asserted brokerConnected($url) { outputState('connected to ' + url); }
    +      on retracted brokerConnected($url) { outputState('disconnected from ' + url); }
    +
    +      during jQueryInput('#wsurl', $url) {
    +        assert brokerConnection(url);
    +
    +        on message Syndicate.WakeDetector.wakeEvent() {
    +          :: forceBrokerDisconnect(url);
    +        }
    +
    +        assert toBroker(url, present(this.nym, this.status));
    +        during fromBroker(url, present($who, $status)) {
    +          assert DOM('#nymlist', 'present-nym', Syndicate.seal(
    +            ["li",
    +             ["span", [["class", "nym"]], who],
    +             ["span", [["class", "nym_status"]], status]]));
    +        }
    +
    +        on message jQueryEvent('#send_chat', 'click', _) {
    +          var inp = $("#chat_input");
    +          var utterance = inp.val();
    +          inp.val("");
    +          if (utterance) :: toBroker(url, says(this.nym, utterance));
    +        }
    +
    +        on message fromBroker(url, says($who, $what)) {
    +          outputUtterance(who, what);
    +        }
    +      }
    +    }
    +  }
    +}
    +
    +///////////////////////////////////////////////////////////////////////////
    +// Adding items to the transcript panel (plain Javascript/jQuery)
    +
    +function outputItem(item) {
    +  var stamp = $("").text((new Date()).toGMTString()).addClass("timestamp");
    +  var item = $("
    ").append([stamp].concat(item)); + var o = $("#chat_output"); + o.append(item); + o[0].scrollTop = o[0].scrollHeight; + return item; +} + +function outputState(state) { + outputItem([$("").text(state).addClass(state).addClass("state")]) + .addClass("state_" + state); +} + +function outputUtterance(who, what) { + outputItem([$("").text(who).addClass("nym"), + $("").text(what).addClass("utterance")]).addClass("utterance"); +} + +/////////////////////////////////////////////////////////////////////////// +// Input control value monitoring + +assertion type jQueryInput(selector, value); + +function spawnInputChangeMonitor() { + actor { + forever { + on asserted Syndicate.observe(jQueryInput($selector, _)) { + actor { + this.value = $(selector).val(); + state { + assert jQueryInput(selector, this.value); + on message jQueryEvent(selector, 'change', $e) { + this.value = e.target.value; + } + } until { + case retracted Syndicate.observe(jQueryInput(selector, _)); + } + } + } + } + } +} + +/////////////////////////////////////////////////////////////////////////// +// Main + +$(document).ready(function () { + ground dataspace G { + Syndicate.JQuery.spawnJQueryDriver(); + Syndicate.DOM.spawnDOMDriver(); + Syndicate.WakeDetector.spawnWakeDetector(); + spawnBrokerClientDriver(); + spawnInputChangeMonitor(); + spawnChatApp(); + } + + // G.dataspace.setOnStateChange(function (mux, patch) { + // $("#ds-state").text(Syndicate.prettyTrie(mux.routingTable)); + // }); +}); diff --git a/js/examples/chat/style.css b/js/examples/chat/style.css new file mode 100644 index 0000000..a0fe84b --- /dev/null +++ b/js/examples/chat/style.css @@ -0,0 +1,97 @@ +h1 { + background: lightgrey; +} + +body > section { + display: flex; +} + +body > section > section { + margin: 1em; +} + +section#messages { + flex-grow: 3; +} + +section#active_users { + flex-grow: 1; +} + +form#chat_form { + flex: 1 100%; +} + +span.timestamp { + color: #d0d0d0; +} + +span.timestamp:after { + content: " "; +} + +.utterance span.nym:after { + content: ": "; +} + +span.arrived:after { + content: " arrived"; +} + +span.departed:after { + content: " departed"; +} + +div.notification { + background-color: #eeeeff; +} + +span.state.connected, span.arrived { + color: #00c000; +} + +span.state.disconnected, span.departed { + color: #c00000; +} + +span.state.crashed { + color: white; + background: red; +} + +span.state.crashed:after { + content: "; please reload the page"; +} + +div.state_disconnected { + background-color: #ffeeee; +} + +div.state_connected { + background-color: #eeffee; +} + +#chat_output { + height: 15em; + overflow-y: scroll; +} + +#chat_input { + width: 80%; +} + +.nym { + color: #00c000; +} + +.nym_status:before { + content: " ("; +} + +.nym_status:after { + content: ")"; +} + +.nym_status { + font-size: smaller; +} diff --git a/js/examples/jquery/index.js b/js/examples/jquery/index.js index 41d24ea..891cc0b 100644 --- a/js/examples/jquery/index.js +++ b/js/examples/jquery/index.js @@ -19,7 +19,7 @@ $(document).ready(function () { handleEvent: function (e) { if (e.type === 'message' && Syndicate.JQuery.jQueryEvent.isClassOf(e.message) - && e.message.selector === '#clicker') + && e.message[0] === '#clicker') { var r = $('#result'); r.html(Number(r.html()) + 1); diff --git a/js/examples/smoketest/index.js b/js/examples/smoketest/index.js index 3d4cae5..a960659 100644 --- a/js/examples/smoketest/index.js +++ b/js/examples/smoketest/index.js @@ -1,6 +1,6 @@ "use strict"; -var beep = Syndicate.Struct.makeStructureConstructor('beep', ['counter']); +var beep = Syndicate.Struct.makeConstructor('beep', ['counter']); var G; $(document).ready(function () { @@ -26,7 +26,7 @@ $(document).ready(function () { boot: function () { return sub(beep.pattern); }, handleEvent: function (e) { if (e.type === 'message') { - console.log("beep!", e.message.counter); + console.log("beep!", e.message[0]); } } }); diff --git a/js/examples/textfield/index.js b/js/examples/textfield/index.js index a29361b..ec8ff04 100644 --- a/js/examples/textfield/index.js +++ b/js/examples/textfield/index.js @@ -8,9 +8,9 @@ var __ = Syndicate.__; var _$ = Syndicate._$; var jQueryEvent = Syndicate.JQuery.jQueryEvent; -var fieldContents = Syndicate.Struct.makeStructureConstructor('fieldContents', ['text', 'pos']); -var highlight = Syndicate.Struct.makeStructureConstructor('highlight', ['state']); -var fieldCommand = Syndicate.Struct.makeStructureConstructor('fieldCommand', ['detail']); +var fieldContents = Syndicate.Struct.makeConstructor('fieldContents', ['text', 'pos']); +var highlight = Syndicate.Struct.makeConstructor('highlight', ['state']); +var fieldCommand = Syndicate.Struct.makeConstructor('fieldCommand', ['detail']); function escapeText(text) { text = text.replace(/&/g, '&'); @@ -47,7 +47,7 @@ function spawnGui() { var self = this; switch (e.type) { case "message": - var event = e.message.eventValue; + var event = e.message[2]; var keycode = event.keyCode; var character = String.fromCharCode(event.charCode); if (keycode === 37 /* left */) { @@ -105,7 +105,7 @@ function spawnModel() { handleEvent: function (e) { if (e.type === "message" && fieldCommand.isClassOf(e.message)) { - var command = e.message.detail; + var command = e.message[0]; if (command === "cursorLeft") { this.cursorPos--; if (this.cursorPos < 0) diff --git a/js/src/ack.js b/js/src/ack.js index 9db2da4..32057bc 100644 --- a/js/src/ack.js +++ b/js/src/ack.js @@ -5,7 +5,7 @@ var Dataspace = require('./dataspace.js').Dataspace; var Struct = require('./struct.js'); var Patch = require('./patch.js'); -var ack = Struct.makeStructureConstructor('ack', ['id']); +var ack = Struct.makeConstructor('ack', ['id']); function Ack(metaLevel, id) { this.metaLevel = metaLevel || 0; @@ -26,7 +26,7 @@ Ack.prototype.check = function (e) { if (!this.done) { if (e.type === 'message') { var m = Patch.stripAtMeta(e.message, this.metaLevel); - if (ack.isClassOf(m) && m.id === this.id) { + if (ack.isClassOf(m) && m[0] === this.id) { this.disarm(); this.done = true; } diff --git a/js/src/actor.js b/js/src/actor.js index 8e71383..ba963fc 100644 --- a/js/src/actor.js +++ b/js/src/actor.js @@ -19,14 +19,17 @@ function Actor(state, bootFn) { this.mux = new Mux.Mux(); this.boot = function() { - bootFn.call(this.state); - this.checkForTermination(); + var self = this; + withCurrentFacet(null, function () { + bootFn.call(self.state); + }); + self.checkForTermination(); }; } Actor.prototype.handleEvent = function(e) { this.facets.forEach(function (f) { - f.handleEvent(e); + withCurrentFacet(f, function () { f.handleEvent(e); }); }); this.checkForTermination(); }; @@ -56,14 +59,32 @@ function Facet(actor) { this.endpoints = Immutable.Map(); this.initBlocks = Immutable.List(); this.doneBlocks = Immutable.List(); + this.children = Immutable.Set(); + this.parent = Facet.current; +} + +Facet.current = null; + +function withCurrentFacet(facet, f) { + var previous = Facet.current; + Facet.current = facet; + var result; + try { + result = f(); + } catch (e) { + Facet.current = previous; + throw e; + } + Facet.current = previous; + return result; } Facet.prototype.handleEvent = function(e) { var facet = this; - this.endpoints.forEach(function(endpoint) { + facet.endpoints.forEach(function(endpoint) { endpoint.handlerFn.call(facet.actor.state, e); }); - this.refresh(); + facet.refresh(); }; Facet.prototype.addAssertion = function(assertionFn) { @@ -157,6 +178,9 @@ Facet.prototype.refresh = function() { Facet.prototype.completeBuild = function() { var facet = this; this.actor.addFacet(this); + if (this.parent) { + this.parent.children = this.parent.children.add(this); + } this.initBlocks.forEach(function(b) { b.call(facet.actor.state); }); }; @@ -169,8 +193,14 @@ Facet.prototype.terminate = function() { }); Dataspace.stateChange(aggregate); this.endpoints = Immutable.Map(); + if (this.parent) { + this.parent.children = this.parent.children.remove(this); + } this.actor.removeFacet(this); this.doneBlocks.forEach(function(b) { b.call(facet.actor.state); }); + this.children.forEach(function (child) { + child.terminate(); + }); }; //--------------------------------------------------------------------------- diff --git a/js/src/dataspace.js b/js/src/dataspace.js index 3cf5e9e..6234748 100644 --- a/js/src/dataspace.js +++ b/js/src/dataspace.js @@ -41,6 +41,7 @@ function Dataspace(bootFn) { // Class state and methods +Dataspace.noisy = false; Dataspace.stack = Immutable.List(); Dataspace.current = function () { @@ -140,7 +141,7 @@ Dataspace.prototype.asChild = function (pid, f, omitLivenessCheck) { Dataspace.prototype.kill = function (pid, exn) { if (exn && exn.stack) { console.log("Process exiting", pid, exn, exn.stack); - } else { + } else if (exn || Dataspace.noisy) { console.log("Process exiting", pid, exn); } var p = this.processTable.get(pid); @@ -222,53 +223,53 @@ Dataspace.prototype.interpretAction = function (pid, action) { var self = this; switch (action.type) { - case 'stateChange': - var oldMux = this.mux.shallowCopy(); - this.deliverPatches(oldMux, this.mux.updateStream(pid, action.patch)); - return true; + case 'stateChange': + var oldMux = this.mux.shallowCopy(); + this.deliverPatches(oldMux, this.mux.updateStream(pid, action.patch)); + return true; - case 'message': - if (Patch.observe.isClassOf(action.message)) { - console.warn('Process ' + pid + ' send message containing query', action.message); - } - if (pid !== 'meta' && Patch.atMeta.isClassOf(action.message)) { - Dataspace.send(action.message.assertion); - } else { - this.mux.routeMessage(action.message).forEach(function (pid) { - self.deliverEvent(pid, action); - }); - } - return true; + case 'message': + if (Patch.observe.isClassOf(action.message)) { + console.warn('Process ' + pid + ' send message containing query', action.message); + } + if (pid !== 'meta' && Patch.atMeta.isClassOf(action.message)) { + Dataspace.send(action.message[0]); + } else { + this.mux.routeMessage(action.message).forEach(function (pid) { + self.deliverEvent(pid, action); + }); + } + return true; - case 'spawn': - var oldMux = this.mux.shallowCopy(); - var p = { behavior: action.behavior }; - var pid = this.mux.nextPid; - this.processTable = this.processTable.set(pid, p); - var initialPatch = Patch.emptyPatch; - if (p.behavior.boot) { - initialPatch = this.asChild(pid, function () { return p.behavior.boot() }); - initialPatch = initialPatch || Patch.emptyPatch; - this.markRunnable(pid); - } - this.deliverPatches(oldMux, this.mux.addStream(initialPatch)); - return true; + case 'spawn': + var oldMux = this.mux.shallowCopy(); + var p = { behavior: action.behavior }; + var pid = this.mux.nextPid; + this.processTable = this.processTable.set(pid, p); + var initialPatch = Patch.emptyPatch; + if (p.behavior.boot) { + initialPatch = this.asChild(pid, function () { return p.behavior.boot() }); + initialPatch = initialPatch || Patch.emptyPatch; + this.markRunnable(pid); + } + this.deliverPatches(oldMux, this.mux.addStream(initialPatch)); + return true; - case 'terminate': - var oldMux = this.mux.shallowCopy(); - this.deliverPatches(oldMux, this.mux.removeStream(pid)); - console.log("Process exit complete", pid); - this.processTable = this.processTable.remove(pid); - return true; + case 'terminate': + var oldMux = this.mux.shallowCopy(); + this.deliverPatches(oldMux, this.mux.removeStream(pid)); + if (Dataspace.noisy) console.log("Process exit complete", pid); + this.processTable = this.processTable.remove(pid); + return true; - case 'terminateDataspace': - Dataspace.exit(); - return false; + case 'terminateDataspace': + Dataspace.exit(); + return false; - default: - var exn = new Error("Action type " + action.type + " not understood"); - exn.action = action; - throw exn; + default: + var exn = new Error("Action type " + action.type + " not understood"); + exn.action = action; + throw exn; } }; diff --git a/js/src/demand-matcher.js b/js/src/demand-matcher.js index 73f6729..c6cdd55 100644 --- a/js/src/demand-matcher.js +++ b/js/src/demand-matcher.js @@ -3,34 +3,78 @@ var Trie = require('./trie.js'); var Patch = require('./patch.js'); var Util = require('./util.js'); -function DemandMatcher(demandSpec, supplySpec, options) { +function ensureMatchingProjectionNames(specs) { + if (!(specs.length > 0)) { + throw new Error("Syndicate: DemandMatcher needs at least one spec"); + } + + var names = null; + specs.forEach(function (spec) { + if (names === null) { + names = Trie.projectionNames(spec); + } else { + if (JSON.stringify(names) !== JSON.stringify(Trie.projectionNames(spec))) { + throw new Error("Syndicate: DemandMatcher needs identical capture names"); + } + } + }); + return names; +} + +function defaultHandler(side, movement) { + return function (captures) { + console.error("Syndicate: Unhandled "+movement+" in "+side, captures); + }; +} + +function DemandMatcher(demandSpecs, supplySpecs, options) { options = Util.extend({ metaLevel: 0, - onDemandIncrease: function (captures) { - console.error("Syndicate: Unhandled increase in demand", captures); - }, - onSupplyDecrease: function (captures) { - console.error("Syndicate: Unhandled decrease in supply", captures); - } + demandMetaLevel: null, + supplyMetaLevel: null, + onDemandIncrease: defaultHandler('demand', 'increase'), + onDemandDecrease: function (captures) {}, + onSupplyIncrease: function (captures) {}, + onSupplyDecrease: defaultHandler('supply', 'decrease') }, options); - this.metaLevel = options.metaLevel; + + this.demandProjectionNames = ensureMatchingProjectionNames(demandSpecs); + this.supplyProjectionNames = ensureMatchingProjectionNames(supplySpecs); + + this.demandSpecs = demandSpecs; + this.supplySpecs = supplySpecs; + + this.demandPatterns = demandSpecs.map(function (s) { return Trie.projectionToPattern(s); }); + this.supplyPatterns = supplySpecs.map(function (s) { return Trie.projectionToPattern(s); }); + + this.demandMetaLevel = + (options.demandMetaLevel === null) ? options.metaLevel : options.demandMetaLevel; + this.supplyMetaLevel = + (options.supplyMetaLevel === null) ? options.metaLevel : options.supplyMetaLevel; + + function metaWrap(n) { + return function (s) { return Patch.prependAtMeta(s, n); }; + } + this.demandProjections = demandSpecs.map(metaWrap(this.demandMetaLevel)); + this.supplyProjections = supplySpecs.map(metaWrap(this.supplyMetaLevel)); + this.onDemandIncrease = options.onDemandIncrease; + this.onDemandDecrease = options.onDemandDecrease; + this.onSupplyIncrease = options.onSupplyIncrease; this.onSupplyDecrease = options.onSupplyDecrease; - this.demandSpec = demandSpec; - this.supplySpec = supplySpec; - this.demandPattern = Trie.projectionToPattern(demandSpec); - this.supplyPattern = Trie.projectionToPattern(supplySpec); - this.demandProjection = Patch.prependAtMeta(demandSpec, this.metaLevel); - this.supplyProjection = Patch.prependAtMeta(supplySpec, this.metaLevel); - this.demandProjectionNames = Trie.projectionNames(this.demandProjection); - this.supplyProjectionNames = Trie.projectionNames(this.supplyProjection); + this.currentDemand = Immutable.Set(); this.currentSupply = Immutable.Set(); } DemandMatcher.prototype.boot = function () { - return Patch.sub(this.demandPattern, this.metaLevel) - .andThen(Patch.sub(this.supplyPattern, this.metaLevel)); + var p = Patch.emptyPatch; + function extend(ml) { + return function (pat) { p = p.andThen(Patch.sub(pat, ml)); }; + } + this.demandPatterns.forEach(extend(this.demandMetaLevel)); + this.supplyPatterns.forEach(extend(this.supplyMetaLevel)); + return p; }; DemandMatcher.prototype.handleEvent = function (e) { @@ -39,26 +83,29 @@ DemandMatcher.prototype.handleEvent = function (e) { } }; +DemandMatcher.prototype.extractKeys = function (trie, projections, keyCount, whichSide) { + var ks = Immutable.Set(); + projections.forEach(function (proj) { + var moreKs = Trie.trieKeys(Trie.project(trie, proj), keyCount); + if (!moreKs) { + throw new Error("Syndicate: wildcard "+whichSide+" detected:\n" + + JSON.stringify(proj) + "\n" + + Trie.prettyTrie(trie)); + } + ks = ks.union(moreKs); + }); + return ks; +}; + DemandMatcher.prototype.handlePatch = function (p) { var self = this; var dN = self.demandProjectionNames.length; var sN = self.supplyProjectionNames.length; - var addedDemand = Trie.trieKeys(Trie.project(p.added, self.demandProjection), dN); - var removedDemand = Trie.trieKeys(Trie.project(p.removed, self.demandProjection), dN); - var addedSupply = Trie.trieKeys(Trie.project(p.added, self.supplyProjection), sN); - var removedSupply = Trie.trieKeys(Trie.project(p.removed, self.supplyProjection), sN); - - if (addedDemand === null) { - throw new Error("Syndicate: wildcard demand detected:\n" + - self.demandSpec + "\n" + - p.pretty()); - } - if (addedSupply === null) { - throw new Error("Syndicate: wildcard supply detected:\n" + - self.supplySpec + "\n" + - p.pretty()); - } + var addedDemand = this.extractKeys(p.added, self.demandProjections, dN, 'demand'); + var removedDemand = this.extractKeys(p.removed, self.demandProjections, dN, 'demand'); + var addedSupply = this.extractKeys(p.added, self.supplyProjections, sN, 'supply'); + var removedSupply = this.extractKeys(p.removed, self.supplyProjections, sN, 'supply'); self.currentSupply = self.currentSupply.union(addedSupply); self.currentDemand = self.currentDemand.subtract(removedDemand); @@ -68,6 +115,17 @@ DemandMatcher.prototype.handlePatch = function (p) { self.onSupplyDecrease(Trie.captureToObject(captures, self.supplyProjectionNames)); } }); + addedSupply.forEach(function (captures) { + if (!self.currentDemand.has(captures)) { + self.onSupplyIncrease(Trie.captureToObject(captures, self.supplyProjectionNames)); + } + }); + + removedDemand.forEach(function (captures) { + if (self.currentSupply.has(captures)) { + self.onDemandDecrease(Trie.captureToObject(captures, self.demandProjectionNames)); + } + }); addedDemand.forEach(function (captures) { if (!self.currentSupply.has(captures)) { self.onDemandIncrease(Trie.captureToObject(captures, self.demandProjectionNames)); diff --git a/js/src/dom-driver.js b/js/src/dom-driver.js index cc31969..e63dcfa 100644 --- a/js/src/dom-driver.js +++ b/js/src/dom-driver.js @@ -10,14 +10,14 @@ var Dataspace = Dataspace_.Dataspace; var __ = Dataspace_.__; var _$ = Dataspace_._$; -var DOM = Struct.makeStructureConstructor('DOM', ['selector', 'fragmentClass', 'fragmentSpec']); +var DOM = Struct.makeConstructor('DOM', ['selector', 'fragmentClass', 'fragmentSpec']); function spawnDOMDriver(domWrapFunction, jQueryWrapFunction) { domWrapFunction = domWrapFunction || DOM; var spec = domWrapFunction(_$('selector'), _$('fragmentClass'), _$('fragmentSpec')); Dataspace.spawn( - new DemandMatcher(spec, - Patch.advertise(spec), // TODO: are the embedded captures problematic here? If not, why not? + new DemandMatcher([spec], + [Patch.advertise(spec)], { onDemandIncrease: function (c) { Dataspace.spawn(new DOMFragment(c.selector, @@ -106,7 +106,7 @@ DOMFragment.prototype.interpretSpec = function (spec) { var attrs = hasAttrs ? spec[1] : {}; var kidIndex = hasAttrs ? 2 : 1; - // Wow! Such XSS! Many hacks! So vulnerability! Amaze! + // TODO: Wow! Such XSS! Many hacks! So vulnerability! Amaze! var n = document.createElement(tagName); for (var i = 0; i < attrs.length; i++) { n.setAttribute(attrs[i][0], attrs[i][1]); @@ -124,6 +124,9 @@ DOMFragment.prototype.buildNodes = function () { var self = this; var nodes = []; $(self.selector).each(function (index, domNode) { + if (!(self.fragmentSpec instanceof Syndicate.Seal)) { + throw new Error("DOM fragmentSpec not contained in a Syndicate.Seal: " + JSON.stringify(self.fragmentSpec)); + } var n = self.interpretSpec(self.fragmentSpec.sealContents); if ('classList' in n) { n.classList.add(self.fragmentClass); diff --git a/js/src/jquery-driver.js b/js/src/jquery-driver.js index 9a7c883..8f6ab2d 100644 --- a/js/src/jquery-driver.js +++ b/js/src/jquery-driver.js @@ -8,14 +8,14 @@ var Dataspace = Dataspace_.Dataspace; var __ = Dataspace_.__; var _$ = Dataspace_._$; -var jQueryEvent = Struct.makeStructureConstructor('jQueryEvent', ['selector', 'eventName', 'eventValue']); +var jQueryEvent = Struct.makeConstructor('jQueryEvent', ['selector', 'eventName', 'eventValue']); function spawnJQueryDriver(baseSelector, metaLevel, wrapFunction) { metaLevel = metaLevel || 0; wrapFunction = wrapFunction || jQueryEvent; Dataspace.spawn( - new DemandMatcher(Patch.observe(wrapFunction(_$('selector'), _$('eventName'), __)), - Patch.advertise(wrapFunction(_$('selector'), _$('eventName'), __)), + new DemandMatcher([Patch.observe(wrapFunction(_$('selector'), _$('eventName'), __))], + [Patch.advertise(wrapFunction(_$('selector'), _$('eventName'), __))], { metaLevel: metaLevel, onDemandIncrease: function (c) { diff --git a/js/src/main.js b/js/src/main.js index 5cf862b..f2130ee 100644 --- a/js/src/main.js +++ b/js/src/main.js @@ -29,8 +29,7 @@ module.exports.Ack = require('./ack.js').Ack; module.exports.RandomID = require('./randomid.js'); module.exports.DOM = require("./dom-driver.js"); module.exports.JQuery = require("./jquery-driver.js"); -// module.exports.RoutingTableWidget = require("./routing-table-widget.js"); -// module.exports.WebSocket = require("./websocket-driver.js"); +module.exports.WakeDetector = require("./wake-detector-driver.js"); module.exports.Reflect = require("./reflect.js"); module.exports.Patch = require("./patch.js"); diff --git a/js/src/mux.js b/js/src/mux.js index ff4ba4e..5058bf1 100644 --- a/js/src/mux.js +++ b/js/src/mux.js @@ -45,7 +45,7 @@ Mux.prototype.updateStream = function (pid, unclampedPatch) { }; var atMetaEverything = Trie.compilePattern(true, Patch.atMeta(Trie.__)); -var atMetaBranchKeys = Immutable.List([[Patch.atMeta.meta.arguments.length, Patch.atMeta.meta]]); +var atMetaBranchKeys = Immutable.List([[Patch.atMeta.meta.arity, Patch.atMeta.meta]]); var onlyMeta = Trie.trieSuccess(Immutable.Set.of("meta")); function echoCancelledTrie(t) { @@ -60,8 +60,7 @@ function computeEvents(oldMux, newMux, updateStreamResult) { var deltaAggregate = updateStreamResult.deltaAggregate; var deltaAggregateNoEcho = (actingPid === "meta") ? delta // because echo-cancellation means that meta-SCNs are always new information - : new Patch.Patch(Trie.triePruneBranch(deltaAggregate.added, atMetaBranchKeys), - Trie.triePruneBranch(deltaAggregate.removed, atMetaBranchKeys)); + : deltaAggregate.withoutAtMeta(); var oldRoutingTable = oldMux.routingTable; var newRoutingTable = newMux.routingTable; var affectedPids = @@ -95,8 +94,7 @@ function computeEvents(oldMux, newMux, updateStreamResult) { function computeAffectedPids(routingTable, delta) { var cover = Trie._union(delta.added, delta.removed); - routingTable = - Trie.trieStep(routingTable, Patch.observe.meta.arguments.length, Patch.observe.meta); + routingTable = Trie.trieStep(routingTable, Patch.observe.meta.arity, Patch.observe.meta); return Trie.matchTrie(cover, routingTable, Immutable.Set(), function (v1, v2, acc) { return acc.union(v2); }); } diff --git a/js/src/patch.js b/js/src/patch.js index 60973f3..8ee3339 100644 --- a/js/src/patch.js +++ b/js/src/patch.js @@ -16,9 +16,9 @@ var emptyPatch = new Patch(Trie.emptyTrie, Trie.emptyTrie); var removeEverythingPatch = new Patch(Trie.emptyTrie, Trie.compilePattern(true, __)); var trueLabel = Trie.trieSuccess(true); -var observe = Struct.makeStructureConstructor('observe', ['assertion']); -var atMeta = Struct.makeStructureConstructor('atMeta', ['assertion']); -var advertise = Struct.makeStructureConstructor('advertise', ['assertion']); +var observe = Struct.makeConstructor('observe', ['assertion']); +var atMeta = Struct.makeConstructor('at-meta', ['assertion']); +var advertise = Struct.makeConstructor('advertise', ['assertion']); function prependAtMeta(p, level) { while (level--) { @@ -30,7 +30,7 @@ function prependAtMeta(p, level) { function stripAtMeta(p, level) { while (level--) { if (atMeta.isClassOf(p)) { - p = p.assertion; + p = p[0]; } else { return null; } @@ -97,11 +97,11 @@ Patch.prototype.isNonEmpty = function () { }; Patch.prototype.hasAdded = function () { - return this.added !== Trie.emptyTrie; + return !Trie.is_emptyTrie(this.added); }; Patch.prototype.hasRemoved = function () { - return this.removed !== Trie.emptyTrie; + return !Trie.is_emptyTrie(this.removed); }; Patch.prototype.lift = function () { @@ -196,7 +196,7 @@ function computePatch(oldBase, newBase) { } function biasedIntersection(object, subject) { - subject = Trie.trieStep(subject, observe.meta.arguments.length, observe.meta); + subject = Trie.trieStep(subject, observe.meta.arity, observe.meta); return Trie.intersect(object, subject, function (v1, v2) { return Trie.trieSuccess(v1); }); } @@ -226,6 +226,27 @@ Patch.prototype.pretty = function () { return ("<<<<<<<< Removed:\n" + Trie.prettyTrie(this.removed) + "\n" + "======== Added:\n" + Trie.prettyTrie(this.added) + "\n" + ">>>>>>>>"); +}; + +// Completely ignores success-values in t. +Patch.prototype.prunedBy = function (t) { + return new Patch(Trie.subtract(this.added, t, function (v1, v2) { return Trie.emptyTrie; }), + Trie.subtract(this.removed, t, function (v1, v2) { return Trie.emptyTrie; })); +}; + +var atMetaEverything = Trie.compilePattern(true, atMeta.pattern); +Patch.prototype.withoutAtMeta = function () { + return this.prunedBy(atMetaEverything); +}; + +/////////////////////////////////////////////////////////////////////////// + +Patch.prototype.toJSON = function () { + return [Trie.trieToJSON(this.added), Trie.trieToJSON(this.removed)]; +}; + +function fromJSON(j) { + return new Patch(Trie.trieFromJSON(j[0]), Trie.trieFromJSON(j[1])); } /////////////////////////////////////////////////////////////////////////// @@ -251,3 +272,5 @@ module.exports.unpub = unpub; module.exports.patchSeq = patchSeq; module.exports.computePatch = computePatch; module.exports.biasedIntersection = biasedIntersection; + +module.exports.fromJSON = fromJSON; diff --git a/js/src/struct.js b/js/src/struct.js index fddb12b..935dfce 100644 --- a/js/src/struct.js +++ b/js/src/struct.js @@ -1,61 +1,106 @@ "use strict"; // "Structures": Simple named-tuple-like records. -// TODO: shore up $SyndicateMeta$, making it a proper object var Immutable = require("immutable"); var $Special = require('./special.js'); -/* Defined here rather than in trie.js because we need it in makeStructureConstructor. */ +/* Defined here rather than in trie.js because we need it in makeConstructor. */ var __ = new $Special("wildcard"); /* wildcard marker */ -function instantiateStructure($SyndicateMeta$, argvals) { - var result = {"$SyndicateMeta$": $SyndicateMeta$}; - var argnames = $SyndicateMeta$.arguments; - for (var i = 0; i < argnames.length; i++) { - result[argnames[i]] = argvals[i]; - } - return result; -} +function StructureType(label, arity) { + this.label = label; + this.arity = arity; + this.pattern = this.instantiate(Immutable.Repeat(__, arity).toArray()); -function makeStructureConstructor(label, argumentNames) { - var $SyndicateMeta$ = { - label: label, - arguments: argumentNames + var self = this; + this.ctor = function () { + return self.instantiate(Array.prototype.slice.call(arguments)); }; - var ctor = function() { - return instantiateStructure($SyndicateMeta$, arguments); - }; - ctor.prototype._get = function(i) { return this[this.$SyndicateMeta$.arguments[i]]; }; - ctor.meta = $SyndicateMeta$; - ctor.isClassOf = function (v) { return v && v.$SyndicateMeta$ === $SyndicateMeta$; }; - ctor.pattern = ctor.apply(null, Immutable.Repeat(__, argumentNames.length).toArray()); - return ctor; + this.ctor.meta = this; + this.ctor.pattern = this.pattern; + this.ctor.isClassOf = function (v) { return self.isClassOf(v); }; } -function isSyndicateMeta(m) { - // TODO: include more structure in $SyndicateMeta$ objects to make - // this judgement less sloppy. - return m && m.label && Array.isArray(m.arguments); +function makeConstructor(label, fieldNames) { + return new StructureType(label, fieldNames.length).ctor; } -function isStructure(s) { - return (s !== null) && (typeof s === 'object') && ("$SyndicateMeta$" in s); -} +StructureType.prototype.equals = function (other) { + if (!(other instanceof StructureType)) return false; + return this.arity === other.arity && this.label === other.label; +}; -function structureToArray(s, excludeLabel) { - var result = excludeLabel ? [] : [s.$SyndicateMeta$.label]; - var args = s.$SyndicateMeta$.arguments; - for (var i = 0; i < args.length; i++) { - result.push(s[args[i]]); +StructureType.prototype.instantiate = function (fields) { + return new Structure(this, fields); +}; + +StructureType.prototype.isClassOf = function (v) { + return v && (v instanceof Structure) && (v.meta.equals(this)); +}; + +function Structure(meta, fields) { + if (!isStructureType(meta)) { + throw new Error("Structure: requires structure type"); } - return result; + if (fields.length !== meta.arity) { + throw new Error("Structure: cannot instantiate meta "+JSON.stringify(meta.label)+ + " expecting "+meta.arity+" fields with "+fields.length+" fields"); + } + this.meta = meta; + this.length = meta.arity; + this.fields = fields.slice(0); + for (var i = 0; i < fields.length; i++) { + this[i] = fields[i]; + } +} + +function reviveStructs(j) { + if (Array.isArray(j)) { + return j.map(reviveStructs); + } + + if ((j !== null) && typeof j === 'object') { + if ((typeof j['@type'] === 'string') && Array.isArray(j['fields'])) { + return (new StructureType(j['@type'], j['fields'].length)).instantiate(j['fields']); + } else { + for (var k in j) { + if (Object.prototype.hasOwnProperty.call(j, k)) { + j[k] = reviveStructs(j[k]); + } + } + return j; + } + } + + return j; +} + +function reviver(k, v) { + if (k === '') { + return reviveStructs(v); + } + return v; +}; + +Structure.prototype.toJSON = function () { + return { '@type': this.meta.label, 'fields': this.fields }; +}; + +function isStructureType(v) { + return v && (v instanceof StructureType); +} + +function isStructure(v) { + return v && (v instanceof Structure); } /////////////////////////////////////////////////////////////////////////// module.exports.__ = __; -module.exports.instantiateStructure = instantiateStructure; -module.exports.makeStructureConstructor = makeStructureConstructor; -module.exports.isSyndicateMeta = isSyndicateMeta; +module.exports.StructureType = StructureType; +module.exports.makeConstructor = makeConstructor; +module.exports.Structure = Structure; +module.exports.reviveStructs = reviveStructs; +module.exports.reviver = reviver; +module.exports.isStructureType = isStructureType; module.exports.isStructure = isStructure; -module.exports.structureToArray = structureToArray; diff --git a/js/src/trie.js b/js/src/trie.js index bca7154..629c060 100644 --- a/js/src/trie.js +++ b/js/src/trie.js @@ -186,11 +186,10 @@ function compilePattern(v, p) { } if (Struct.isStructure(p)) { - var args = p.$SyndicateMeta$.arguments; - for (var i = args.length - 1; i >= 0; i--) { - acc = walk(p[args[i]], acc); + for (var i = p.meta.arity - 1; i >= 0; i--) { + acc = walk(p[i], acc); } - return rseq(args.length, p.$SyndicateMeta$, acc); + return rseq(p.meta.arity, p.meta, acc); } if (p instanceof $Embedded) { @@ -222,13 +221,10 @@ function matchPattern(v, p) { if (p === __) return; - if (Struct.isStructure(p) - && Struct.isStructure(v) - && (p.$SyndicateMeta$ === v.$SyndicateMeta$)) + if (Struct.isStructure(p) && Struct.isStructure(v) && (p.meta.equals(v.meta))) { - var args = p.$SyndicateMeta$.arguments; - for (var i = 0; i < args.length; i++) { - walk(v[args[i]], p[args[i]]); + for (var i = 0; i < p.meta.arity; i++) { + walk(v[i], p[i]); } return; } @@ -431,8 +427,8 @@ function matchValue(r, v, failureResultOpt) { r = rlookup(r, v.length, SOA); vs = Immutable.List(v).concat(vs); } else if (Struct.isStructure(v)) { - r = rlookup(r, v.$SyndicateMeta$.arguments.length, v.$SyndicateMeta$); - vs = Immutable.List(Struct.structureToArray(v, true)).concat(vs); + r = rlookup(r, v.meta.arity, v.meta); + vs = Immutable.List(v.fields).concat(vs); } else { r = rlookup(r, 0, v); } @@ -501,17 +497,19 @@ function appendTrie(m, mTailFn) { } } -function triePruneBranch(m, arityKeys) { - if (arityKeys.isEmpty()) return emptyTrie; - if (!(m instanceof $Branch)) return m; - var arityKey = arityKeys.first(); - var rest = arityKeys.shift(); - var arity = arityKey[0]; - var key = arityKey[1]; - m = rcopybranch(m); - rupdate_inplace(m, arity, key, triePruneBranch(rlookup(m, arity, key), rest)); - return collapse(m); -} +// DANGEROUS: prefer subtract() instead. +// +// function triePruneBranch(m, arityKeys) { +// if (arityKeys.isEmpty()) return emptyTrie; +// if (!(m instanceof $Branch)) return m; +// var arityKey = arityKeys.first(); +// var rest = arityKeys.shift(); +// var arity = arityKey[0]; +// var key = arityKey[1]; +// m = rcopybranch(m); +// rupdate_inplace(m, arity, key, triePruneBranch(rlookup(m, arity, key), rest)); +// return collapse(m); +// } function trieStep(m, arity, key) { if (typeof key === 'undefined') { @@ -550,8 +548,7 @@ function projectionNames(p) { } if (Struct.isStructure(p)) { - var args = p.$SyndicateMeta$.arguments; - for (var i = 0; i < args.length; i++) walk(p[args[i]]); + for (var i = 0; i < p.meta.arity; i++) walk(p[i]); return; } } @@ -576,12 +573,11 @@ function projectionToPattern(p) { } if (Struct.isStructure(p)) { - var result = {"$SyndicateMeta$": p.$SyndicateMeta$}; - var args = p.$SyndicateMeta$.arguments; - for (var i = 0; i < args.length; i++) { - result[args[i]] = walk(p[args[i]]); + var resultFields = []; + for (var i = 0; i < p.meta.arity; i++) { + resultFields[i] = walk(p[i]); } - return result; + return new Struct.Structure(p.meta, resultFields); } return p; @@ -652,15 +648,13 @@ function projectMany(t, wholeSpecs, projectSuccessOpt, combinerOpt) { } if (Struct.isStructure(spec)) { - var arity = spec.$SyndicateMeta$.arguments.length; - var key = spec.$SyndicateMeta$; var intermediate = walk(isCapturing, - rlookup(t, arity, key), - Immutable.List(Struct.structureToArray(spec, true)), + rlookup(t, spec.meta.arity, spec.meta), + Immutable.List(spec.fields), function (intermediate) { return walk(isCapturing, intermediate, specsRest, kont); }); - return isCapturing ? rseq(arity, key, intermediate) : intermediate; + return isCapturing ? rseq(spec.meta.arity, spec.meta, intermediate) : intermediate; } if (Array.isArray(spec)) { @@ -687,7 +681,7 @@ function reconstructSequence(key, items) { if (key === SOA) { return items.toArray(); } else { - return Struct.instantiateStructure(key, items); + return key.instantiate(items); } } @@ -722,7 +716,7 @@ function trieKeys(m, takeCount0) { if (result === false) return false; // break out of iteration var piece; - if (Struct.isSyndicateMeta(key) || key === SOA) { // TODO: this is sloppy + if (Struct.isStructureType(key) || key === SOA) { piece = walk(k, arity, Immutable.List(), function (items, m1) { var item = reconstructSequence(key, items); return walk(m1, takeCount - 1, valsRev.unshift(item), kont); @@ -798,10 +792,11 @@ function prettyTrie(m, initialIndent) { } acc.push(" "); if (key === SOA) key = '<' + arity + '>'; - else if (Struct.isSyndicateMeta(key)) key = key.label + '<' + arity + '>'; + else if (Struct.isStructureType(key)) key = key.label + '<' + arity + '>'; else if (key instanceof $Special) key = key.name; - else if (typeof key === 'undefined') key = 'undefined'; - else key = JSON.stringify(key); + else key = JSON.stringify(key); + + if (typeof key === 'undefined') key = 'undefined'; acc.push(key); walk(i + key.length + 1, k); }); @@ -815,6 +810,78 @@ function prettyTrie(m, initialIndent) { /////////////////////////////////////////////////////////////////////////// +function parenTypeToString(key) { + if (Struct.isStructureType(key)) { + return ':' + key.label; + } else { + return 'L'; + } +} + +function stringToParenType(arity, key) { + if (key[0] === ':') { + return new Struct.StructureType(key.slice(1), arity); + } else if (key === 'L') { + return SOA; + } + throw new Error("Unsupported JSON trie paren type: "+key); +} + +function trieToJSON(t) { + if (is_emptyTrie(t)) { return []; } + if (t instanceof $Success) { return [true]; } // TODO: consider generalizing + + // It's a $Branch. + + var jParens = []; + var jAtoms = []; + t.edges.forEach(function (keymap, arity) { + keymap.forEach(function (k, key) { + var jk = trieToJSON(k); + if (Struct.isStructureType(key) || key === SOA) { + jParens.push([arity, parenTypeToString(key), jk]); + } else { + jAtoms.push([key, jk]); + } + }); + }); + return [jParens, trieToJSON(t.wild), jAtoms]; +} + +function badJSON(j) { + die("Cannot deserialize JSON trie: " + JSON.stringify(j)); +} + +function trieFromJSON(j) { + return decode(j); + + function decode(j) { + if (!Array.isArray(j)) badJSON(j); + + switch (j.length) { + case 0: return emptyTrie; + case 1: return rsuccess(true); // TODO: consider generalizing + case 3: { + var result = rcopybranch(expand(rwild(decode(j[1])))); + j[0].forEach(function (entry) { + var arity = entry[0]; + if (typeof arity !== 'number') badJSON(j); + var key = stringToParenType(arity, entry[1]); + rupdate_inplace(result, arity, key, decode(entry[2])); + }); + j[2].forEach(function (entry) { + var key = entry[0]; + rupdate_inplace(result, 0, key, decode(entry[1])); + }); + return collapse(result); + } + default: badJSON(j); + } + } +} + +/////////////////////////////////////////////////////////////////////////// + module.exports.__ = __; module.exports.SOA = SOA; module.exports.$Capture = $Capture; @@ -833,7 +900,7 @@ module.exports.subtract = subtract; module.exports.matchValue = matchValue; module.exports.matchTrie = matchTrie; module.exports.appendTrie = appendTrie; -module.exports.triePruneBranch = triePruneBranch; +// module.exports.triePruneBranch = triePruneBranch; module.exports.trieStep = trieStep; module.exports.trieSuccess = rsuccess; module.exports.relabel = relabel; @@ -847,6 +914,8 @@ module.exports.captureToObject = captureToObject; module.exports.trieKeysToObjects = trieKeysToObjects; module.exports.projectObjects = projectObjects; module.exports.prettyTrie = prettyTrie; +module.exports.trieToJSON = trieToJSON; +module.exports.trieFromJSON = trieFromJSON; // For testing module.exports._testing = { diff --git a/js/src/wake-detector-driver.js b/js/src/wake-detector-driver.js new file mode 100644 index 0000000..1534e60 --- /dev/null +++ b/js/src/wake-detector-driver.js @@ -0,0 +1,47 @@ +"use strict"; +// Wake detector - notices when something (such as +// suspension/sleeping!) has caused periodic activities to be +// interrupted, and warns others about it +// Inspired by http://blog.alexmaccaw.com/javascript-wake-event + +var Patch = require("./patch.js"); +var Struct = require('./struct.js'); + +var Dataspace_ = require("./dataspace.js"); +var Dataspace = Dataspace_.Dataspace; +var __ = Dataspace_.__; +var _$ = Dataspace_._$; + +var wakeEvent = Struct.makeConstructor('wakeEvent', []); + +function spawnWakeDetector(periodOpt) { + Dataspace.spawn(new WakeDetector(periodOpt)); +} + +function WakeDetector(periodOpt) { + this.period = periodOpt || 10000; + this.mostRecentTrigger = +(new Date()); + this.timerId = null; +} + +WakeDetector.prototype.boot = function () { + var self = this; + this.timerId = setInterval(Dataspace.wrap(function () { self.trigger(); }), this.period); + return Patch.pub(wakeEvent()); +}; + +WakeDetector.prototype.handleEvent = function (e) {}; + +WakeDetector.prototype.trigger = function () { + var now = +(new Date()); + if (now - this.mostRecentTrigger > this.period * 1.5) { + Dataspace.send(wakeEvent()); + } + this.mostRecentTrigger = now; +}; + +/////////////////////////////////////////////////////////////////////////// + +module.exports.spawnWakeDetector = spawnWakeDetector; +module.exports.WakeDetector = WakeDetector; +module.exports.wakeEvent = wakeEvent; diff --git a/js/test/test-patch.js b/js/test/test-patch.js index 4fe7ba6..6bca0c0 100644 --- a/js/test/test-patch.js +++ b/js/test/test-patch.js @@ -40,23 +40,23 @@ describe('basic patch compilation', function () { [' <2> 1 2 {true}'], [' ::: nothing']); checkPrettyPatch(Patch.assert([1, 2], 1), - [' atMeta<1> <2> 1 2 {true}'], + [' at-meta<1> <2> 1 2 {true}'], [' ::: nothing']); checkPrettyPatch(Patch.assert([1, 2], 2), - [' atMeta<1> atMeta<1> <2> 1 2 {true}'], + [' at-meta<1> at-meta<1> <2> 1 2 {true}'], [' ::: nothing']); checkPrettyPatch(Patch.sub([1, 2], 0), [' observe<1> <2> 1 2 {true}'], [' ::: nothing']); checkPrettyPatch(Patch.sub([1, 2], 1), - [' atMeta<1> observe<1> <2> 1 2 {true}', - ' observe<1> atMeta<1> <2> 1 2 {true}'], + [' at-meta<1> observe<1> <2> 1 2 {true}', + ' observe<1> at-meta<1> <2> 1 2 {true}'], [' ::: nothing']); checkPrettyPatch(Patch.sub([1, 2], 2), - [' atMeta<1> atMeta<1> observe<1> <2> 1 2 {true}', - ' observe<1> atMeta<1> <2> 1 2 {true}', - ' observe<1> atMeta<1> atMeta<1> <2> 1 2 {true}'], + [' at-meta<1> at-meta<1> observe<1> <2> 1 2 {true}', + ' observe<1> at-meta<1> <2> 1 2 {true}', + ' observe<1> at-meta<1> at-meta<1> <2> 1 2 {true}'], [' ::: nothing']); }); }); @@ -111,14 +111,14 @@ describe('patch sequencing', function () { describe('patch lifting', function () { it('should basically work', function () { checkPrettyPatch(Patch.assert([1, 2]).lift(), - [' atMeta<1> <2> 1 2 {true}'], + [' at-meta<1> <2> 1 2 {true}'], [' ::: nothing']); checkPrettyPatch(Patch.sub([1, 2]).lift(), - [' atMeta<1> observe<1> <2> 1 2 {true}'], + [' at-meta<1> observe<1> <2> 1 2 {true}'], [' ::: nothing']); checkPrettyPatch(Patch.assert([1, 2]).andThen(Patch.assert(Patch.atMeta([1, 2]))).lift(), - [' atMeta<1> atMeta<1> <2> 1 2 {true}', - ' <2> 1 2 {true}'], + [' at-meta<1> at-meta<1> <2> 1 2 {true}', + ' <2> 1 2 {true}'], [' ::: nothing']); }); }); diff --git a/js/test/test-route.js b/js/test/test-route.js index ef1d7dc..5e5b6c1 100644 --- a/js/test/test-route.js +++ b/js/test/test-route.js @@ -402,7 +402,7 @@ describe("calls to matchPattern", function () { }); it("matches structures", function () { - var ctor = Struct.makeStructureConstructor('foo', ['bar', 'zot']); + var ctor = Struct.makeConstructor('foo', ['bar', 'zot']); expect(r.matchPattern(ctor(123, 234), ctor(r._$("bar"), r._$("zot")))) .to.eql({ bar: 123, zot: 234, length: 2 }); // Previously, structures were roughly the same as arrays: @@ -466,75 +466,76 @@ describe('intersect', function () { }); }); -describe('triePruneBranch', function () { - it('should not affect empty trie', function () { - checkPrettyTrie(r.triePruneBranch(r.emptyTrie, Immutable.List([])), [' ::: nothing']); - checkPrettyTrie(r.triePruneBranch(r.emptyTrie, Immutable.List([r.SOA])), [' ::: nothing']); - checkPrettyTrie(r.triePruneBranch(r.emptyTrie, Immutable.List(["x"])), [' ::: nothing']); - checkPrettyTrie(r.triePruneBranch(r.emptyTrie, Immutable.List([r.SOA, "x"])), [' ::: nothing']); - }); +// describe('triePruneBranch', function () { +// it('should not affect empty trie', function () { +// checkPrettyTrie(r.triePruneBranch(r.emptyTrie, Immutable.List([])), [' ::: nothing']); +// checkPrettyTrie(r.triePruneBranch(r.emptyTrie, Immutable.List([r.SOA])), [' ::: nothing']); +// checkPrettyTrie(r.triePruneBranch(r.emptyTrie, Immutable.List(["x"])), [' ::: nothing']); +// checkPrettyTrie(r.triePruneBranch(r.emptyTrie, Immutable.List([r.SOA, "x"])), [' ::: nothing']); +// }); +// +// it('should leave a hole in a full trie', function () { +// var full = r.compilePattern(true, r.__); +// checkPrettyTrie(r.triePruneBranch(full, Immutable.List([])), [' ::: nothing']); +// checkPrettyTrie(r.triePruneBranch(full, Immutable.List([[0, r.SOA]])), +// [' ★ {true}', +// ' <0> ::: nothing']); +// checkPrettyTrie(r.triePruneBranch(full, Immutable.List([[0, "x"]])), +// [' ★ {true}', +// ' "x" ::: nothing']); +// checkPrettyTrie(r.triePruneBranch(full, Immutable.List([[2, r.SOA], [0, "x"]])), +// [' ★ {true}', +// ' <2> ★ ★ {true}', +// ' "x" ::: nothing']); +// }); +// +// it('should prune in a finite tree and leave the rest alone', function () { +// var A = r.compilePattern(true, ["y"]) +// var B = r.union(r.compilePattern(true, ["x"]), A); +// var C = r.union(r.compilePattern(true, "z"), B); +// checkPrettyTrie(r.triePruneBranch(A, Immutable.List([])), [' ::: nothing']); +// checkPrettyTrie(r.triePruneBranch(B, Immutable.List([])), [' ::: nothing']); +// checkPrettyTrie(r.triePruneBranch(C, Immutable.List([])), [' ::: nothing']); +// checkPrettyTrie(r.triePruneBranch(A, Immutable.List([[0, "z"]])), [' <1> "y" {true}']); +// checkPrettyTrie(r.triePruneBranch(B, Immutable.List([[0, "z"]])), [' <1> "x" {true}', +// ' "y" {true}']); +// checkPrettyTrie(r.triePruneBranch(C, Immutable.List([[0, "z"]])), [' <1> "x" {true}', +// ' "y" {true}']); +// checkPrettyTrie(r.triePruneBranch(A, Immutable.List([[1, r.SOA]])), [' ::: nothing']); +// checkPrettyTrie(r.triePruneBranch(B, Immutable.List([[1, r.SOA]])), [' ::: nothing']); +// checkPrettyTrie(r.triePruneBranch(C, Immutable.List([[1, r.SOA]])), [' "z" {true}']); +// var px = [[1, r.SOA], [0, "x"]]; +// checkPrettyTrie(r.triePruneBranch(A, Immutable.List(px)), [' <1> "y" {true}']); +// checkPrettyTrie(r.triePruneBranch(B, Immutable.List(px)), [' <1> "y" {true}']); +// checkPrettyTrie(r.triePruneBranch(C, Immutable.List(px)), [' "z" {true}', +// ' <1> "y" {true}']); +// var py = [[1, r.SOA], [0, "y"]]; +// checkPrettyTrie(r.triePruneBranch(A, Immutable.List(py)), [' ::: nothing']); +// checkPrettyTrie(r.triePruneBranch(B, Immutable.List(py)), [' <1> "x" {true}']); +// checkPrettyTrie(r.triePruneBranch(C, Immutable.List(py)), [' "z" {true}', +// ' <1> "x" {true}']); +// }); +// }); - it('should leave a hole in a full trie', function () { - var full = r.compilePattern(true, r.__); - checkPrettyTrie(r.triePruneBranch(full, Immutable.List([])), [' ::: nothing']); - checkPrettyTrie(r.triePruneBranch(full, Immutable.List([[0, r.SOA]])), - [' ★ {true}', - ' <0> ::: nothing']); - checkPrettyTrie(r.triePruneBranch(full, Immutable.List([[0, "x"]])), - [' ★ {true}', - ' "x" ::: nothing']); - checkPrettyTrie(r.triePruneBranch(full, Immutable.List([[2, r.SOA], [0, "x"]])), - [' ★ {true}', - ' <2> ★ ★ {true}', - ' "x" ::: nothing']); - }); - - it('should prune in a finite tree and leave the rest alone', function () { - var A = r.compilePattern(true, ["y"]) - var B = r.union(r.compilePattern(true, ["x"]), A); - var C = r.union(r.compilePattern(true, "z"), B); - checkPrettyTrie(r.triePruneBranch(A, Immutable.List([])), [' ::: nothing']); - checkPrettyTrie(r.triePruneBranch(B, Immutable.List([])), [' ::: nothing']); - checkPrettyTrie(r.triePruneBranch(C, Immutable.List([])), [' ::: nothing']); - checkPrettyTrie(r.triePruneBranch(A, Immutable.List([[0, "z"]])), [' <1> "y" {true}']); - checkPrettyTrie(r.triePruneBranch(B, Immutable.List([[0, "z"]])), [' <1> "x" {true}', - ' "y" {true}']); - checkPrettyTrie(r.triePruneBranch(C, Immutable.List([[0, "z"]])), [' <1> "x" {true}', - ' "y" {true}']); - checkPrettyTrie(r.triePruneBranch(A, Immutable.List([[1, r.SOA]])), [' ::: nothing']); - checkPrettyTrie(r.triePruneBranch(B, Immutable.List([[1, r.SOA]])), [' ::: nothing']); - checkPrettyTrie(r.triePruneBranch(C, Immutable.List([[1, r.SOA]])), [' "z" {true}']); - var px = [[1, r.SOA], [0, "x"]]; - checkPrettyTrie(r.triePruneBranch(A, Immutable.List(px)), [' <1> "y" {true}']); - checkPrettyTrie(r.triePruneBranch(B, Immutable.List(px)), [' <1> "y" {true}']); - checkPrettyTrie(r.triePruneBranch(C, Immutable.List(px)), [' "z" {true}', - ' <1> "y" {true}']); - var py = [[1, r.SOA], [0, "y"]]; - checkPrettyTrie(r.triePruneBranch(A, Immutable.List(py)), [' ::: nothing']); - checkPrettyTrie(r.triePruneBranch(B, Immutable.List(py)), [' <1> "x" {true}']); - checkPrettyTrie(r.triePruneBranch(C, Immutable.List(py)), [' "z" {true}', - ' <1> "x" {true}']); - }); -}); - -describe('makeStructureConstructor', function () { +describe('makeConstructor', function () { it('should produce the right metadata', function () { - var ctor = Struct.makeStructureConstructor('foo', ['bar', 'zot']); + var ctor = Struct.makeConstructor('foo', ['bar', 'zot']); var inst = ctor(123, 234); - expect(inst.$SyndicateMeta$.label).to.equal('foo'); - expect(inst.$SyndicateMeta$.arguments).to.eql(['bar', 'zot']); + expect(inst.meta.label).to.equal('foo'); + expect(inst.meta.arity).to.equal(2); + expect(ctor.meta).to.equal(inst.meta); }); it('should produce the right instance data', function () { - var ctor = Struct.makeStructureConstructor('foo', ['bar', 'zot']); + var ctor = Struct.makeConstructor('foo', ['bar', 'zot']); var inst = ctor(123, 234); - expect(inst.bar).to.equal(123); - expect(inst.zot).to.equal(234); + expect(inst[0]).to.equal(123); + expect(inst[1]).to.equal(234); }); it('should work with compilePattern and matchValue', function () { var sA = Immutable.Set(["A"]); - var ctor = Struct.makeStructureConstructor('foo', ['bar', 'zot']); + var ctor = Struct.makeConstructor('foo', ['bar', 'zot']); var inst = ctor(123, 234); var x = r.compilePattern(sA, ctor(123, r.__)); checkPrettyTrie(x, [' foo<2> 123 ★ {["A"]}']); diff --git a/js/test/test-syndicate.js b/js/test/test-syndicate.js index 91b5edb..7588c04 100644 --- a/js/test/test-syndicate.js +++ b/js/test/test-syndicate.js @@ -105,10 +105,10 @@ describe("nested actor with an echoey protocol", function () { }, ['<<<<<<<< Removed:\n'+ ' ::: nothing\n'+ '======== Added:\n'+ - ' atMeta<1> "X" {["meta"]}\n'+ + ' at-meta<1> "X" {["meta"]}\n'+ '>>>>>>>>', '<<<<<<<< Removed:\n'+ - ' atMeta<1> "X" {["meta"]}\n'+ + ' at-meta<1> "X" {["meta"]}\n'+ '======== Added:\n'+ ' ::: nothing\n'+ '>>>>>>>>']);