The Event Sourced Content Repository

The Event Sourced Content Repository

At #neoscon 2018 in Hamburg, we presented the current state of the event sourced #neoscms Content Repository.

30c0b6f50f67163bee8500aa4115d126?s=128

Sebastian Kurfürst

April 14, 2018
Tweet

Transcript

  1. The Event Sourced Content Repository

  2. Robert Lemke @robertlemke

  3. Sebastian Kurfürst @skurfuerst

  4. Bernhard Schmitt @nezaniel

  5. None
  6. The Content Repository

  7. None
  8. neosio (Page) de en Node Variants Content Dimensions

  9. Workspaces live review-redesign user-johnny

  10. Limits of the current CR

  11. internal code is
 grown and complex

  12. performance limitations

  13. recursive moving of nodes

  14. hard to evolve with new features

  15. Event Sourcing?

  16. Basic Idea 
 (Event Sourcing & CQRS)

  17. Big Picture Before NodeInterface API Doctrine ORM NodeData Repository DB

  18. Big Picture Before Commands State Queries

  19. write side read side Event store (DB Table) Projection 1

    (e.g. various DB tables) Commands Events Projection 2 (soft) constraints
  20. write side read side Event store (DB Table) Content Graph

    Commands Events Workspace Dirtyness (soft) constraints …
  21. COMMAND Update node properties properties: text: oldValue: "Down the Rabbit-"

    newValue: "Down the Rabbit-Hole" nodeAggregateIdentifier: uuid dimensionSpacePoint: ... contentStreamIdentifier: uuid Down the Rabbit-Hole Editing With Events
  22. EVENT STORE EVENT Node properties were updated properties: text: oldValue:

    "Down the Rabbit-" newValue: "Down the Rabbit-Hole" nodeAggregateIdentifier: uuid dimensionSpacePoint: ... contentStreamIdentifier: uuid COMMAND HANDLER e1 Editing With Events COMMAND Update node properties properties: text: oldValue: "Down the Rabbit-" newValue: "Down the Rabbit-Hole" nodeAggregateIdentifier: uuid dimensionSpacePoint: ... contentStreamIdentifier: uuid
  23. e1 EVENT STORE e1 Node properties were updated e2 NodeAggregateWithNode

    was created e3 Node properties were updated e4 Create Workspace Editing With Events
  24. Workspaces 
 aka Content Streams

  25. Workspaces e1 Live Workspace A e2 e3 merging works

  26. Workspaces e1 Live Workspace A e2 e3 merge conflict e4

  27. Workspaces e1 Live Workspace A e2 e3 merge successful e4

    e2’ e3’
  28. Content Stream 1 user-sebastian Content Stream 2

  29. changes to live workspace 
 are not immediately visible!

  30. immediately consistent strongly consistent not consistent eventually consistent

  31. Demo Time

  32. None
  33. None
  34. None
  35. None
  36. Using the new Content Repository

  37. As an integrator: before… content { teaser = Neos.Neos:ContentCollection {

    nodePath = 'teaser' } // Default content section main = Neos.Neos:PrimaryContent { nodePath = 'main' } } // A shared footer which can be edited from all pages footer = Neos.Neos:ContentCollection { nodePath = ${q(site).children('footer').property('_path')} collection = ${q(site).children('footer').children()} }
  38. …and after the rewrite content { teaser = Neos.Neos:ContentCollection {

    nodePath = 'teaser' } // Default content section main = Neos.Neos:PrimaryContent { nodePath = 'main' } } // A shared footer which can be edited from all pages footer = Neos.Neos:ContentCollection { @context.node = ${q(site).children('footer').get(0)} // TODO: using absolute nodePaths does not work yet. // nodePath = ${q(site).children('footer').property('_path')} collection = ${q(site).children('footer').children()} }
  39. As a developer: The current interface…

  40. 1 <?php 2 namespace Neos\ContentRepository\Domain\Model; 3 4 /* 5 *

    This file is part of the Neos.ContentRepository package. 6 * 7 * (c) Contributors of the Neos Project - www.neos.io 8 * 9 * This package is Open Source Software. For the full copyright and license 10 * information, please view the LICENSE file which was distributed with this 11 * source code. 12 */ 13 use Neos\ContentRepository\Domain\Model\Node; 14 use Neos\ContentRepository\Domain\Model\NodeData; 15 use Neos\ContentRepository\Domain\Model\NodeType; 16 use Neos\ContentRepository\Domain\Model\Workspace; 17 use Neos\ContentRepository\Domain\Service\Context; 18 use Neos\ContentRepository\Exception\NodeException; 19 use Neos\ContentRepository\Exception\NodeExistsException; 20 21 /** 22 * Interface for a Node 23 * 24 * @api 25 */ 26 interface NodeInterface 27 { 28 /** 29 * Regex pattern which matches a node path without any context 30 information 31 */ 32 const MATCH_PATTERN_PATH = '/^(\/|(?:\/[a-z0-9\-]+)+)$/'; 33 34 /** 35 * Regex pattern which matches a "context path", ie. a node path possibly 36 containing context information such as the 37 * workspace name. This pattern is used at least in the route part 38 handler. 39 */ 40 const MATCH_PATTERN_CONTEXTPATH = '/^ # A Context Path consists of... 41 (?>(?P<NodePath> # 1) a NODE PATH 42 (?> 43 \/ [a-z0-9\-]+ | # Which either starts with a slash …is rather cumbersome
  41. 1 <?php 2 namespace Neos\ContentRepository\Domain\Model; 3 4 /* 5 *

    This file is part of the Neos.ContentRepository package. 6 * 7 * (c) Contributors of the Neos Project - www.neos.io 8 * 9 * This package is Open Source Software. For the full copyright and license 10 * information, please view the LICENSE file which was distributed with this 11 * source code. 12 */ 13 use Neos\ContentRepository\Domain\Model\Node; 14 use Neos\ContentRepository\Domain\Model\NodeData; 15 use Neos\ContentRepository\Domain\Model\NodeType; 16 use Neos\ContentRepository\Domain\Model\Workspace; 17 use Neos\ContentRepository\Domain\Service\Context; 18 use Neos\ContentRepository\Exception\NodeException; 19 use Neos\ContentRepository\Exception\NodeExistsException; 20 21 /** 22 * Interface for a Node 23 * 24 * @api 25 */ 26 interface NodeInterface 27 { 28 /** 29 * Regex pattern which matches a node path without any context 30 information 31 */ 32 const MATCH_PATTERN_PATH = '/^(\/|(?:\/[a-z0-9\-]+)+)$/'; 33 34 /** 35 * Regex pattern which matches a "context path", ie. a node path possibly 36 containing context information such as the 37 * workspace name. This pattern is used at least in the route part 38 handler. 39 */ 40 const MATCH_PATTERN_CONTEXTPATH = '/^ # A Context Path consists of... 41 (?>(?P<NodePath> # 1) a NODE PATH 42 (?> 43 \/ [a-z0-9\-]+ | # Which either starts with a slash 44 followed by a node name 45 \/ | # OR just a slash (the root node) 46 [a-z0-9\-]+ # OR only a node name (if it is a relative 47 path) 48 ) 49 (?: # and (optionally) more path-parts) 50 \/ 51 [a-z0-9\-]+ 52 )* 53 )) 54 (?: # 2) a CONTEXT 55 @ # which is delimited from the node path 56 by the "@" sign 57 (?>(?P<WorkspaceName> # followed by the workspace name ( 58 NON-EMPTY) 59 [a-z0-9\-]+ 60 )) 61 (?: # OPTIONALLY followed by dimension 62 values 63 ; # ... which always start with ";" 64 (?P<Dimensions> 65 (?> # A Dimension Value is a key=value 66 structure 67 [a-zA-Z_]+ 68 = 69 [^=&]+ 70 ) 71 (?>&(?-1))? # ... delimited by & 72 )){0,1} 73 ){0,1}$/ix'; 74 75 /** 76 * Regex pattern which matches a Node Name (ie. segment of a node path) 77 */ 78 const MATCH_PATTERN_NAME = '/^[a-z0-9\-]+$/'; 79 80 /** 81 * Set the name of the node to $newName, keeping it's position as it is 82 * 83 * @param string $newName 84 * @return void 85 * @throws \InvalidArgumentException if $newName is invalid 86 * @api 87 */ 88 public function setName($newName); 89 90 /** 91 * Returns the name of this node 92 * 93 * @return string 94 * @api 95 */ 96 public function getName(); 97 98 /** 99 * Returns a full length plain text label of this node 100 * 101 * @return string 102 * @api 103 */ 104 public function getLabel(); 105 106 /** 107 * Sets the specified property. 108 * 109 * If the node has a content object attached, the property will be set 110 there 111 * if it is settable. 112 * 113 * @param string $propertyName Name of the property 114 * @param mixed $value Value of the property 115 * @return void 116 * @api 117 */ 118 public function setProperty($propertyName, $value); 119 120 /** 121 * If this node has a property with the given name. 122 * 123 * If the node has a content object attached, the property will be 124 checked 125 * there. 126 * 127 * @param string $propertyName Name of the property to test for 128 * @return boolean 129 * @api 130 */ 131 public function hasProperty($propertyName); 132 133 /** 134 * Returns the specified property. 135 * 136 * If the node has a content object attached, the property will be 137 fetched 138 * there if it is gettable. 139 * 140 * @param string $propertyName Name of the property 141 * @return mixed value of the property 142 * @throws NodeException if the node does not contain the specified 143 property 144 * @api 145 */ 146 public function getProperty($propertyName); 147 148 /** 149 * Removes the specified property. 150 * 151 * If the node has a content object attached, the property will not be 152 removed on 153 * that object if it exists. 154 * 155 * @param string $propertyName Name of the property 156 * @return void 157 * @throws NodeException if the node does not contain the specified 158 property 159 * @api 160 */ 161 public function removeProperty($propertyName); 162 163 /** 164 * Returns all properties of this node. 165 * 166 * If the node has a content object attached, the properties will be 167 fetched 168 * there. 169 * 170 * @return array Property values, indexed by their name 171 * @api 172 */ 173 public function getProperties(); 174 525 /** 526 * Removes this node and all its child nodes or sets ONLY this node to 527 not being removed. 528 * 529 * @param boolean $removed If TRUE, this node and it's child nodes will 530 be removed or set to be not removed. 531 * @return void 532 * @api 533 */ 534 public function setRemoved($removed); 535 536 /** 537 * If this node is a removed node. 538 * 539 * @return boolean 540 * @api 541 */ 542 public function isRemoved(); 543 544 /** 545 * Tells if this node is "visible". 546 * For this the "hidden" flag and the "hiddenBeforeDateTime" and 547 "hiddenAfterDateTime" dates are 548 * taken into account. 549 * 550 * @return boolean 551 * @api 552 */ 553 public function isVisible(); 554 555 /** 556 * Tells if this node may be accessed according to the current security 557 context. 558 * 559 * @return boolean 560 * @api 561 */ 562 public function isAccessible(); 563 564 /** 565 * Tells if a node, in general, has access restrictions, independent of 566 the 567 * current security context. 568 * 569 * @return boolean 570 * @api 571 */ 572 public function hasAccessRestrictions(); 573 574 /** 575 * Checks if the given $nodeType would be allowed as a child node of this 576 node according to the configured constraints. 577 * 578 * @param NodeType $nodeType 579 * @return boolean TRUE if the passed $nodeType is allowed as child node 580 */ 581 public function isNodeTypeAllowedAsChildNode(NodeType $nodeType); 582 583 /** 584 * Moves this node before the given node 585 * 586 * @param NodeInterface $referenceNode 587 * @return void 588 * @api 589 */ 590 public function moveBefore(NodeInterface $referenceNode); 591 592 /** 593 * Moves this node after the given node 594 * 595 * @param NodeInterface $referenceNode 596 * @return void 597 * @api 598 */ 599 public function moveAfter(NodeInterface $referenceNode); 600 601 /** 602 * Moves this node into the given node 603 * 604 * @param NodeInterface $referenceNode 605 * @return void 606 * @api 607 */ 608 public function moveInto(NodeInterface $referenceNode); 609 610 /** 611 * Copies this node before the given node 612 * 613 * @param NodeInterface $referenceNode 614 * @param string $nodeName 615 * @return NodeInterface 616 * @throws NodeExistsException 617 * @api 618 */ 619 public function copyBefore(NodeInterface $referenceNode, $nodeName); 620 621 /** 622 * Copies this node after the given node 623 * 624 * @param NodeInterface $referenceNode 625 * @param string $nodeName 626 * @return NodeInterface 627 * @throws NodeExistsException 628 * @api 629 */ 630 public function copyAfter(NodeInterface $referenceNode, $nodeName); 631 632 /** 633 * Copies this node to below the given node. The new node will be added 634 behind 635 * any existing sub nodes of the given node. 636 * 637 * @param NodeInterface $referenceNode 638 * @param string $nodeName 639 * @return NodeInterface 640 * @throws NodeExistsException 641 * @api 642 */ 643 public function copyInto(NodeInterface $referenceNode, $nodeName); 644 645 /** 646 * Return the NodeData representation of the node. 647 * 648 * @return NodeData 649 */ 650 public function getNodeData(); 651 652 /** 653 * Return the context of the node 654 * 655 * @return Context 656 */ 657 public function getContext(); 658 659 /** 660 * Return the assigned content dimensions of the node. 661 * 662 * @return array An array of dimensions to array of dimension values 663 */ 664 public function getDimensions(); 665 666 /** 667 * Given a context a new node is returned that is like this node, but 668 * lives in the new context. 669 * 670 * @param Context $context 671 * @return NodeInterface 672 */ 673 public function createVariantForContext($context); 674 675 /** 676 * Determine if this node is configured as auto-created childNode of the 677 parent node. If that is the case, it 678 * should not be deleted. 679 * 680 * @return boolean TRUE if this node is auto-created by the parent. 681 */ 682 public function isAutoCreated(); 683 684 /** 685 * Get other variants of this node (with different dimension values) 686 * 687 * A variant of a node can have different dimension values and path (for 688 non- 689 aggre 690 gate 691 nodes 692 ). 693 * The resulting node instances might belong to a different context. 694 * 695 * @return array<NodeInterface> All node variants of this node (excluding 696 the 697 current 698 node) 699 */ 700 public function getOtherNodeVariants(); 701 } 59 constants and methods • 24 for read access • 24 active record-style
 write / create methods • 11 for traversal 175 /** 176 * Returns the names of all properties of this node. 177 * 178 * @return array Property names 179 * @api 180 */ 181 public function getPropertyNames(); 182 183 /** 184 * Sets a content object for this node. 185 * 186 * @param object $contentObject The content object 187 * @return void 188 * @throws \InvalidArgumentException if the given contentObject is no 189 object. 190 * @api 191 */ 192 public function setContentObject($contentObject); 193 194 /** 195 * Returns the content object of this node (if any). 196 * 197 * @return object The content object or NULL if none was set 198 * @api 199 */ 200 public function getContentObject(); 201 202 /** 203 * Unsets the content object of this node. 204 * 205 * @return void 206 * @api 207 */ 208 public function unsetContentObject(); 209 210 /** 211 * Sets the node type of this node. 212 * 213 * @param NodeType $nodeType 214 * @return void 215 * @api 216 */ 217 public function setNodeType(NodeType $nodeType); 218 219 /** 220 * Returns the node type of this node. 221 * 222 * @return NodeType 223 * @api 224 */ 225 public function getNodeType(); 226 227 /** 228 * Sets the "hidden" flag for this node. 229 * 230 * @param boolean $hidden If TRUE, this Node will be hidden 231 * @return void 232 * @api 233 */ 234 public function setHidden($hidden); 235 236 /** 237 * Returns the current state of the hidden flag 238 * 239 * @return boolean 240 * @api 241 */ 242 public function isHidden(); 243 244 /** 245 * Sets the date and time when this node becomes potentially visible. 246 * 247 * @param \DateTime $dateTime Date before this node should be hidden 248 * @return void 249 * @api 250 */ 251 public function setHiddenBeforeDateTime(\DateTime $dateTime = null); 252 253 /** 254 * Returns the date and time before which this node will be automatically 255 hidden. 256 * 257 * @return \DateTime Date before this node will be hidden 258 * @api 259 */ 260 public function getHiddenBeforeDateTime(); 261 262 /** 263 * Sets the date and time when this node should be automatically hidden 264 * 265 * @param \DateTime $dateTime Date after which this node should be hidden 266 * @return void 267 * @api 268 */ 269 public function setHiddenAfterDateTime(\DateTime $dateTime = null); 270 271 /** 272 * Returns the date and time after which this node will be automatically 273 hidden. 274 * 275 * @return \DateTime Date after which this node will be hidden 276 * @api 277 */ 278 public function getHiddenAfterDateTime(); 279 280 /** 281 * Sets if this node should be hidden in indexes, such as a site 282 navigation. 283 * 284 * @param boolean $hidden TRUE if it should be hidden, otherwise FALSE 285 * @return void 286 * @api 287 */ 288 public function setHiddenInIndex($hidden); 289 290 /** 291 * If this node should be hidden in indexes 292 * 293 * @return boolean 294 * @api 295 */ 296 public function isHiddenInIndex(); 297 298 /** 299 * Sets the roles which are required to access this node 300 * 301 * @param array $accessRoles 302 * @return void 303 * @api 304 */ 305 public function setAccessRoles(array $accessRoles); 306 307 /** 308 * Returns the names of defined access roles 309 * 310 * @return array 311 * @api 312 */ 313 public function getAccessRoles(); 314 315 /** 316 * Returns the path of this node 317 * 318 * Example: /sites/mysitecom/homepage/about 319 * 320 * @return string The absolute node path 321 * @api 322 */ 323 public function getPath(); 324 325 /** 326 * Returns the absolute path of this node with additional context 327 information (such as the workspace name). 328 * 329 * Example: /sites/mysitecom/homepage/about@user-admin 330 * 331 * @return string Node path with context information 332 * @api 333 */ 334 public function getContextPath(); 335 336 /** 337 * Returns the level at which this node is located. 338 * Counting starts with 0 for "/", 1 for "/foo", 2 for "/foo/bar" etc. 339 * 340 * @return integer 341 * @api 342 */ 343 public function getDepth(); 344 345 /** 346 * Sets the workspace of this node. 347 * 348 * This method is only for internal use by the content repository. 349 Changing 350 * the workspace of a node manually may lead to unexpected behavior. 351 * 352 * @param Workspace $workspace 353 * @return void 354 */ 355 public function setWorkspace(Workspace $workspace); 356 357 /** 358 * Returns the workspace this node is contained in 359 * 360 * @return Workspace 361 * @api 362 */ 363 public function getWorkspace(); 364 365 /** 366 * Returns the identifier of this node. 367 * 368 * This UUID is not the same as the technical persistence identifier used 369 by 370 * Flow's persistence framework. It is an additional identifier which is 371 unique 372 * within the same workspace and is used for tracking the same node in 373 across 374 * workspaces. 375 * 376 * It is okay and recommended to use this identifier for synchronisation 377 purposes 378 * as it does not change even if all of the nodes content or its path 379 changes. 380 * 381 * @return string the node's UUID 382 * @api 383 */ 384 public function getIdentifier(); 385 386 /** 387 * Sets the index of this node 388 * 389 * This method is for internal use and must only be used by other nodes! 390 * 391 * @param integer $index The new index 392 * @return void 393 */ 394 public function setIndex($index); 395 396 /** 397 * Returns the index of this node which determines the order among 398 siblings 399 * with the same parent node. 400 * 401 * @return integer 402 */ 403 public function getIndex(); 404 405 /** 406 * Returns the parent node of this node 407 * 408 * @return NodeInterface The parent node or NULL if this is the root node 409 * @api 410 */ 411 public function getParent(); 412 413 /** 414 * Returns the parent node path 415 * 416 * @return string Absolute node path of the parent node 417 * @api 418 */ 419 public function getParentPath(); 420 421 /** 422 * Creates, adds and returns a child node of this node. Also sets default 423 * properties and creates default subnodes. 424 * 425 * @param string $name Name of the new node 426 * @param NodeType $nodeType Node type of the new node (optional) 427 * @param string $identifier The identifier of the node, unique within 428 the workspace, optional(!) 429 * @return Node 430 * @throws \InvalidArgumentException if the node name is not accepted. 431 * @throws NodeExistsException if a node with this path already exists. 432 * @api 433 */ 434 public function createNode($name, NodeType $nodeType = null, $identifier 435 = null); 436 437 /** 438 * Creates, adds and returns a child node of this node, without setting 439 default 440 * properties or creating subnodes. 441 * 442 * For internal use only! 443 * 444 * @param string $name Name of the new node 445 * @param NodeType $nodeType Node type of the new node (optional) 446 * @param string $identifier The identifier of the node, unique within 447 the workspace, optional(!) 448 * @return Node 449 * @throws \InvalidArgumentException if the node name is not accepted. 450 * @throws NodeExistsException if a node with this path already exists. 451 */ 452 public function createSingleNode($name, NodeType $nodeType = null, 453 $identifier = null); 454 455 /** 456 * Creates and persists a node from the given $nodeTemplate as child node 457 * 458 * @param \Neos\ContentRepository\Domain\Model\NodeTemplate $nodeTemplate 459 * @param string $nodeName name of the new node. If not specified the 460 name of the nodeTemplate will be used. 461 * @return NodeInterface the freshly generated node 462 * @api 463 */ 464 public function createNodeFromTemplate(NodeTemplate $nodeTemplate, 465 $nodeName = null); 466 467 /** 468 * Returns a node specified by the given relative path. 469 * 470 * @param string $path Path specifying the node, relative to this node 471 * @return NodeInterface The specified node or NULL if no such node 472 exists 473 * @api 474 */ 475 public function getNode($path); 476 477 /** 478 * Returns the primary child node of this node. 479 * 480 * Which node acts as a primary child node will in the future depend on 481 the 482 * node type. For now it is just the first child node. 483 * 484 * @return NodeInterface The primary child node or NULL if no such node 485 exists 486 * @api 487 */ 488 public function getPrimaryChildNode(); 489 490 /** 491 * Returns all direct child nodes of this node. 492 * If a node type is specified, only nodes of that type are returned. 493 * 494 * @param string $nodeTypeFilter If specified, only nodes with that node 495 type are considered 496 * @param integer $limit An optional limit for the number of nodes to 497 find. Added or removed nodes can still change the number nodes! 498 * @param integer $offset An optional offset for the query 499 * @return array<\Neos\ContentRepository\Domain\Model\NodeInterface> An 500 array of nodes or an empty array if no child nodes matched 501 * @api 502 */ 503 public function getChildNodes($nodeTypeFilter = null, $limit = null, 504 $offset = null); 505 506 /** 507 * Checks if this node has any child nodes. 508 * 509 * @param string $nodeTypeFilter If specified, only nodes with that node 510 type are considered 511 * @return boolean TRUE if this node has child nodes, otherwise FALSE 512 * @api 513 */ 514 public function hasChildNodes($nodeTypeFilter = null); 515 516 /** 517 * Removes this node and all its child nodes. This is an alias for 518 setRemoved(TRUE) 519 * 520 * @return void 521 * @api 522 */ 523 public function remove(); 524
  42. Legacy NodeInterface property access traversal active-record style creation and mutation

    methods TraversableNodeInterface traversal NodeInterface property access Commands creation and “mutation“ operations New
  43. Commands require a lot of information to explicitly express the

    user’s intent Contextual information like ContentStreamIdentifier / Workspace and DimensionSpacePoint will be readily available from the current request Dealing with commands
  44. ContentContext —> ContentSubgraph Things that didn’t make it LinkingService —>

    UriBuilder ContentContextFactory —> ContentGraph NodeDataRepository —> ContentSubgraph
  45. New Features

  46. Bi-directional references • references separated from properties
 → access via

    traversal possible • references modeled as edges n1 n2 n2
  47. Bi-directional references new FlowQuery operations 
 q(node).references(name) q(node).referencing(inboundName)

  48. Ability to create other projections • Search index • Workspace

    changes (“dirty“ nodes) • Undo / redo
  49. None
  50. None
  51. None
  52. Performance

  53. used Plumber for profiling

  54. None
  55. old: 150 queries new: 50 queries uncached

  56. old: 10 queries new: 6 queries cached

  57. old: one query per parent node (at least) new: one

    query, independent how much you load menu rendering
  58. versioning editing notifications better read performance undo/redo intelligent write conflict

    resolution synchronization of content across instances our feature wishes
  59. current status

  60. not finished! still lots of work

  61. technology preview 1 Releasing

  62. git clone https://github.com/neos/neos-development-distribution git checkout -b event-sourced composer install

  63. next steps

  64. reverse node ordering :-) hide nodes access restrictions stabilization multisite

    support delete nodes different move nodes behavior (document vs content) create nodes in UI make stand-alone CR further stabilize and improve event sourcing package interaction between domain models and CR ... ToDos we don't know all of these ToDos yet
  65. When will it be finished?

  66. I don't know.

  67. when the team grows, maybe in a year from now,

    there will be a (quite-stable) beta
  68. maybe we'll do a fundraising

  69. summary

  70. it's the 3rd rewrite! ... and this is usually a

    good one :)
  71. None