├── .gitignore ├── Actions.js ├── ItemsStore.js ├── ItemsStoreFetcher.js ├── ItemsStoreLease.js ├── README.md ├── createContainer.js ├── img ├── architecture.png └── architecture.svg ├── index.js ├── package.json └── test ├── ItemsStoreErrors.js ├── ItemsStoreFetcher.js └── ItemsStoreRead.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | -------------------------------------------------------------------------------- /Actions.js: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License http://www.opensource.org/licenses/mit-license.php 3 | Author Tobias Koppers @sokra 4 | */ 5 | var EventEmitter = require("events").EventEmitter; 6 | 7 | var Actions = module.exports = exports; 8 | 9 | Actions.create = function create(array) { 10 | var obj = {}; 11 | if(Array.isArray(array)) { 12 | array.forEach(function(name) { 13 | obj[name] = create(); 14 | }); 15 | return obj; 16 | } else { 17 | var ee = new EventEmitter(); 18 | var action = function() { 19 | var args = Array.prototype.slice.call(arguments); 20 | ee.emit("trigger", args); 21 | }; 22 | action.listen = function(callback, bindContext) { 23 | ee.addListener("trigger", function(args) { 24 | callback.apply(bindContext, args); 25 | }); 26 | }; 27 | return action; 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /ItemsStore.js: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License http://www.opensource.org/licenses/mit-license.php 3 | Author Tobias Koppers @sokra 4 | */ 5 | function ItemsStore(desc, initialData) { 6 | if(!desc || typeof desc !== "object") 7 | throw new Error("Invalid argument: desc must be an object"); 8 | desc.applyUpdate = desc.applyUpdate || applyUpdate; 9 | desc.mergeUpdates = desc.mergeUpdates || mergeUpdates; 10 | desc.rebaseUpdate = desc.rebaseUpdate || rebaseUpdate; 11 | desc.applyNewData = desc.applyNewData || applyNewData; 12 | desc.applyNewError = desc.applyNewError || applyNewError; 13 | desc.queueRequest = desc.queueRequest || process.nextTick.bind(process); 14 | this.desc = desc; 15 | this.items = initialData ? Object.keys(initialData).reduce(function(obj, key) { 16 | obj[key] = { 17 | data: initialData[key], 18 | tick: 0 19 | }; 20 | return obj; 21 | }, {}) : {}; 22 | this.createableItems = []; 23 | this.deletableItems = []; 24 | this.requesting = false; 25 | this.invalidItems = []; 26 | this.updateTick = 0; 27 | this.supportCreate = desc.createSingleItem || desc.createMultipleItems || 28 | desc.createAndReadSingleItem || desc.createAndReadMultipleItems; 29 | this.supportDelete = desc.deleteSingleItem || desc.deleteMultipleItems; 30 | this.supportWrite = desc.writeSingleItem || desc.writeMultipleItems || 31 | desc.writeAndReadSingleItem || desc.writeAndReadMultipleItems; 32 | this.supportRead = desc.readSingleItem || desc.readMultipleItems; 33 | } 34 | 35 | module.exports = ItemsStore; 36 | 37 | /* 38 | 39 | item = { outdated: true } 40 | no item data available and data should be requested 41 | 42 | item = { data: {} } 43 | item data available 44 | 45 | item = { data: {}, outdated: true } 46 | item data available, but data should be renewed by request 47 | 48 | item = { data: {}, update: {}, newData: {} } 49 | item data available, but it should be updated with the "update" and "newData" 50 | 51 | item = { update: {} } 52 | no item data available and it should be updated with the "update" 53 | 54 | */ 55 | 56 | ItemsStore.prototype._createItem = function() { 57 | return { 58 | data: undefined, 59 | update: undefined, 60 | newData: undefined, 61 | error: undefined, 62 | outdated: undefined, 63 | tick: undefined, 64 | handlers: undefined, 65 | infoHandlers: undefined 66 | }; 67 | } 68 | 69 | ItemsStore.prototype.getData = function() { 70 | var data = {}; 71 | var hasData = false; 72 | Object.keys(this.items).forEach(function(key) { 73 | if(this.items[key].data) { 74 | data[key] = this.items[key].data; 75 | hasData = true; 76 | } 77 | }, this); 78 | if(hasData) 79 | return data; 80 | }; 81 | 82 | ItemsStore.prototype.outdate = function(id) { 83 | if(typeof id === "string") { 84 | var item = this.items["$" + id]; 85 | if(!item) return; 86 | item.tick = null; 87 | } else { 88 | this.updateTick++; 89 | } 90 | }; 91 | 92 | ItemsStore.prototype.update = function(allOrId) { 93 | if(typeof allOrId === "string") { 94 | var id = allOrId; 95 | var item = this.items["$" + id]; 96 | if(!item) return; 97 | if(!item.outdated) { 98 | item.outdated = true; 99 | this.invalidateItem(id); 100 | if(item.infoHandlers) { 101 | var handlers = item.infoHandlers.slice(); 102 | handlers.forEach(function(fn) { 103 | fn(item.newData !== undefined ? item.newData : item.data); 104 | }); 105 | } 106 | } 107 | } else { 108 | this.updateTick++; 109 | Object.keys(this.items).forEach(function(key) { 110 | var id = key.substr(1); 111 | var item = this.items[key]; 112 | if(!item) return; 113 | if(!item.outdated && (allOrId || (item.handlers && item.handlers.length > 0))) { 114 | item.outdated = true; 115 | this.invalidateItem(id); 116 | } 117 | }, this); 118 | } 119 | }; 120 | 121 | ItemsStore.prototype.listenToItem = function(id, handler) { 122 | if(typeof handler !== "function") throw new Error("handler argument must be a function"); 123 | var lease = { 124 | close: function lease() { 125 | var item = this.items["$" + id]; 126 | if(!item) return; 127 | var idx = item.handlers.indexOf(handler); 128 | if(idx < 0) return; 129 | item.handlers.splice(idx, 1); 130 | item.leases.splice(idx, 1); 131 | // TODO stream: if item.handlers.length === 0 132 | }.bind(this) 133 | }; 134 | var item = this.items["$" + id]; 135 | if(!item) { 136 | item = this._createItem(); 137 | item.handlers = [handler]; 138 | item.leases = [lease]; 139 | item.outdated = true; 140 | this.items["$" + id] = item; 141 | this.invalidateItem(id); 142 | } else { 143 | if(item.handlers) { 144 | var idx = item.handlers.indexOf(handler); 145 | if(idx >= 0) { 146 | return item.leases[idx]; 147 | } 148 | item.handlers.push(handler); 149 | item.leases.push(lease); 150 | } else { 151 | item.handlers = [handler]; 152 | item.leases = [lease]; 153 | } 154 | if(item.tick !== this.updateTick && !item.outdated) { 155 | item.outdated = true; 156 | this.invalidateItem(id); 157 | } 158 | } 159 | // TODO stream: start streaming 160 | return lease; 161 | } 162 | 163 | ItemsStore.prototype.waitForItem = function(id, callback) { 164 | var self = this; 165 | var onUpdate = function() { 166 | if(!self.isItemUpToDate(id)) return; 167 | var idx = item.infoHandlers.indexOf(onUpdate); 168 | if(idx < 0) return; 169 | item.infoHandlers.splice(idx, 1); 170 | callback(); 171 | }; 172 | 173 | var item = this.items["$" + id]; 174 | if(!item) { 175 | item = this._createItem(); 176 | item.infoHandlers = [onUpdate]; 177 | item.outdated = true; 178 | this.items["$" + id] = item; 179 | this.invalidateItem(id); 180 | } else { 181 | if(this.isItemUpToDate(id)) { 182 | callback(); 183 | return; 184 | } 185 | if(item.infoHandlers) { 186 | item.infoHandlers.push(onUpdate); 187 | } else { 188 | item.infoHandlers = [onUpdate]; 189 | } 190 | if(!item.outdated && item.tick !== this.updateTick) { 191 | item.outdated = true; 192 | this.invalidateItem(id); 193 | } 194 | } 195 | }; 196 | 197 | ItemsStore.prototype.getItem = function(id) { 198 | var item = this.items["$" + id]; 199 | if(!item) return undefined; 200 | return item.newData !== undefined ? item.newData : item.data; 201 | }; 202 | 203 | ItemsStore.prototype.isItemAvailable = function(id) { 204 | var item = this.items["$" + id]; 205 | return !!(item && item.data !== undefined); 206 | }; 207 | 208 | ItemsStore.prototype.isItemUpToDate = function(id) { 209 | var item = this.items["$" + id]; 210 | return !!(item && item.data !== undefined && !item.outdated && item.tick === this.updateTick); 211 | }; 212 | 213 | ItemsStore.prototype.getItemInfo = function(id) { 214 | var item = this.items["$" + id]; 215 | if(!item) return { 216 | available: false, 217 | outdated: false, 218 | updated: false, 219 | listening: false, 220 | error: undefined 221 | }; 222 | return { 223 | available: item.data !== undefined, 224 | outdated: !(!item.outdated && item.tick === this.updateTick), 225 | updated: item.update !== undefined, 226 | listening: !!item.handlers && item.handlers.length > 0, 227 | error: item.error 228 | }; 229 | }; 230 | 231 | ItemsStore.prototype.updateItem = function(id, update) { 232 | if(!this.supportWrite) 233 | throw new Error("Store doesn't support updating of items"); 234 | var item = this.items["$" + id]; 235 | if(!item) { 236 | item = this._createItem(); 237 | item.update = update; 238 | this.items["$" + id] = item; 239 | } else { 240 | if(item.data !== undefined) { 241 | item.newData = this.desc.applyUpdate(item.newData !== undefined ? item.newData : item.data, update); 242 | } 243 | if(item.update !== undefined) { 244 | item.update = this.desc.mergeUpdates(item.update, update); 245 | } else { 246 | item.update = update; 247 | } 248 | } 249 | this.invalidateItem(id); 250 | if(item.data !== undefined && item.handlers) { 251 | var handlers = item.handlers.slice(); 252 | handlers.forEach(function(fn) { 253 | fn(item.newData); 254 | }); 255 | } 256 | }; 257 | 258 | ItemsStore.prototype.createItem = function(data, handler) { 259 | if(!this.supportCreate) 260 | throw new Error("Store doesn't support creating of items"); 261 | this.createableItems.push({ 262 | data: data, 263 | handler: handler 264 | }); 265 | if(!this.requesting) { 266 | this.requesting = true; 267 | this._queueRequest(); 268 | } 269 | }; 270 | 271 | ItemsStore.prototype.deleteItem = function(id, handler) { 272 | if(!this.supportDelete) 273 | throw new Error("Store doesn't support deleting of items"); 274 | this.deletableItems.push({ 275 | id: id, 276 | handler: handler 277 | }); 278 | if(!this.requesting) { 279 | this.requesting = true; 280 | this._queueRequest(); 281 | } 282 | }; 283 | 284 | ItemsStore.prototype.invalidateItem = function(id) { 285 | if(this.invalidItems.indexOf(id) >= 0) 286 | return; 287 | if(!this.supportRead) 288 | throw new Error("Store doesn't support reading of items"); 289 | this.invalidItems.push(id); 290 | if(!this.requesting) { 291 | this.requesting = true; 292 | this._queueRequest(); 293 | } 294 | }; 295 | 296 | ItemsStore.prototype._queueRequest = function() { 297 | this.desc.queueRequest(this._request.bind(this)); 298 | }; 299 | 300 | ItemsStore.prototype._requestWriteAndReadMultipleItems = function(items, callback) { 301 | this.desc.writeAndReadMultipleItems(items, function(err, newDatas) { 302 | if(err) { 303 | items.forEach(function(item) { 304 | this.setItemError(item.id, err); 305 | }, this); 306 | } 307 | if(newDatas) { 308 | Object.keys(newDatas).forEach(function(id) { 309 | this.setItemData(id.substr(1), newDatas[id]); 310 | }, this); 311 | } 312 | this._queueRequest(); 313 | callback(); 314 | }.bind(this)); 315 | }; 316 | 317 | ItemsStore.prototype._requestWriteMultipleItems = function(items, callback) { 318 | this.desc.writeMultipleItems(items, function(err) { 319 | if(err) { 320 | items.forEach(function(item) { 321 | this.setItemError(item.id, err); 322 | }, this); 323 | } 324 | this._queueRequest(); 325 | callback(); 326 | }.bind(this)); 327 | }; 328 | 329 | ItemsStore.prototype._requestWriteAndReadSingleItem = function(item, callback) { 330 | this.desc.writeAndReadSingleItem(item, function(err, newData) { 331 | if(err) { 332 | this.setItemError(item.id, err); 333 | } 334 | if(newData !== undefined) { 335 | this.setItemData(item.id, newData); 336 | } 337 | this._queueRequest(); 338 | callback(); 339 | }.bind(this)); 340 | }; 341 | 342 | ItemsStore.prototype._requestWriteSingleItem = function(item, callback) { 343 | this.desc.writeSingleItem(item, function(err) { 344 | if(err) { 345 | this.setItemError(item.id, err); 346 | } 347 | this._queueRequest(); 348 | callback(); 349 | }.bind(this)); 350 | }; 351 | 352 | ItemsStore.prototype._requestReadMultipleItems = function(items, callback) { 353 | this.desc.readMultipleItems(items, function(err, newDatas) { 354 | if(err) { 355 | items.forEach(function(item) { 356 | this.setItemError(item.id, err); 357 | }, this); 358 | } 359 | if(newDatas) { 360 | Object.keys(newDatas).forEach(function(id) { 361 | this.setItemData(id.substr(1), newDatas[id]); 362 | }, this); 363 | } 364 | this._queueRequest(); 365 | callback(); 366 | }.bind(this)); 367 | }; 368 | 369 | ItemsStore.prototype._requestReadSingleItem = function(item, callback) { 370 | this.desc.readSingleItem(item, function(err, newData) { 371 | if(err) { 372 | this.setItemError(item.id, err); 373 | } 374 | if(newData !== undefined) { 375 | this.setItemData(item.id, newData); 376 | } 377 | this._queueRequest(); 378 | callback(); 379 | }.bind(this)); 380 | }; 381 | 382 | ItemsStore.prototype._requestCreateSingleItem = function(item, callback) { 383 | this.desc.createSingleItem(item, function(err, id) { 384 | if(item.handler) item.handler(err, id); 385 | this._queueRequest(); 386 | callback(); 387 | }.bind(this)); 388 | }; 389 | 390 | ItemsStore.prototype._requestCreateMultipleItems = function(items, callback) { 391 | this.desc.createMultipleItems(items, function(err, ids) { 392 | for(var i = 0; i < items.length; i++) { 393 | if(items[i].handler) { 394 | items[i].handler(err, ids && ids[i]); 395 | } 396 | } 397 | this._queueRequest(); 398 | callback(); 399 | }.bind(this)); 400 | }; 401 | 402 | ItemsStore.prototype._requestCreateAndReadSingleItem = function(item, callback) { 403 | this.desc.createAndReadSingleItem(item, function(err, id, newData) { 404 | if(!err && newData !== undefined) { 405 | this.setItemData(id, newData); 406 | } 407 | if(item.handler) item.handler(err, id, newData); 408 | this._queueRequest(); 409 | callback(); 410 | }.bind(this)); 411 | }; 412 | 413 | ItemsStore.prototype._requestCreateAndReadMultipleItems = function(items, callback) { 414 | this.desc.createAndReadMultipleItems(items, function(err, ids, newDatas) { 415 | if(newDatas) { 416 | Object.keys(newDatas).forEach(function(id) { 417 | this.setItemData(id.substr(1), newDatas[id]); 418 | }, this); 419 | } 420 | for(var i = 0; i < items.length; i++) { 421 | if(items[i].handler) { 422 | items[i].handler(err, ids && ids[i], ids && newDatas && newDatas[ids[i]]); 423 | } 424 | } 425 | this._queueRequest(); 426 | callback(); 427 | }.bind(this)); 428 | }; 429 | 430 | ItemsStore.prototype._requestDeleteSingleItem = function(item, callback) { 431 | this.desc.deleteSingleItem(item, function(err) { 432 | if(item.handler) item.handler(err); 433 | if(!err) { 434 | delete this.items["$" + item.id]; 435 | } 436 | this._queueRequest(); 437 | callback(); 438 | }.bind(this)); 439 | }; 440 | 441 | ItemsStore.prototype._requestDeleteMultipleItems = function(items, callback) { 442 | this.desc.deleteMultipleItems(items, function(err) { 443 | for(var i = 0; i < items.length; i++) { 444 | if(items[i].handler) { 445 | items[i].handler(err); 446 | } 447 | if(!err) { 448 | delete this.items["$" + items[i].id]; 449 | } 450 | } 451 | this._queueRequest(); 452 | callback(); 453 | }.bind(this)); 454 | }; 455 | 456 | ItemsStore.prototype._request = function(callback) { 457 | callback = callback || function () {}; 458 | if(this.desc.createAndReadMultipleItems) { 459 | var items = this._popCreateableItem(true); 460 | if(items.length === 1 && this.desc.createAndReadSingleItem) { 461 | this._requestCreateAndReadSingleItem(items[0], callback); 462 | return; 463 | } else if(items.length > 0) { 464 | this._requestCreateAndReadMultipleItems(items, callback); 465 | return; 466 | } 467 | } 468 | if(this.desc.createMultipleItems) { 469 | var items = this._popCreateableItem(true); 470 | if(items.length === 1 && this.desc.createSingleItem) { 471 | if(!this.desc.createAndReadSingleItem) { 472 | this._requestCreateSingleItem(items[0], callback); 473 | return; 474 | } 475 | } else if(items.length > 0) { 476 | this._requestCreateMultipleItems(items, callback); 477 | return; 478 | } 479 | } 480 | if(this.desc.createAndReadSingleItem) { 481 | var item = this._popCreateableItem(false); 482 | if(item) { 483 | this._requestCreateAndReadSingleItem(item, callback); 484 | return; 485 | } 486 | } 487 | if(this.desc.createSingleItem) { 488 | var item = this._popCreateableItem(false); 489 | if(item) { 490 | this._requestCreateSingleItem(item, callback); 491 | return; 492 | } 493 | } 494 | if(this.desc.writeAndReadMultipleItems) { 495 | var items = this._popWriteableItem(true, true); 496 | if(items.length === 1 && this.desc.writeAndReadSingleItem) { 497 | this._requestWriteAndReadSingleItem(items[0], callback); 498 | return; 499 | } else if(items.length > 0) { 500 | this._requestWriteAndReadMultipleItems(items, callback); 501 | return; 502 | } 503 | } 504 | if(this.desc.writeMultipleItems) { 505 | var items = this._popWriteableItem(true, false); 506 | if(items.length === 1 && this.desc.writeSingleItem) { 507 | if(!this.desc.writeAndReadSingleItem) { 508 | this._requestWriteSingleItem(items[0], callback); 509 | return; 510 | } 511 | } else if(items.length > 0) { 512 | this._requestWriteMultipleItems(items, callback); 513 | return; 514 | } 515 | } 516 | if(this.desc.writeAndReadSingleItem) { 517 | var item = this._popWriteableItem(false, true); 518 | if(item) { 519 | this._requestWriteAndReadSingleItem(item, callback); 520 | return; 521 | } 522 | } 523 | if(this.desc.writeSingleItem) { 524 | var item = this._popWriteableItem(false); 525 | if(item) { 526 | this._requestWriteSingleItem(item, callback); 527 | return; 528 | } 529 | } 530 | if(this.desc.deleteMultipleItems) { 531 | var items = this._popDeleteableItem(true); 532 | if(items.length === 1 && this.desc.deleteSingleItem) { 533 | this._requestDeleteSingleItem(items[0], callback); 534 | return; 535 | } else if(items.length > 0) { 536 | this._requestDeleteMultipleItems(items, callback); 537 | return; 538 | } 539 | } 540 | if(this.desc.deleteSingleItem) { 541 | var item = this._popDeleteableItem(false); 542 | if(item) { 543 | this._requestDeleteSingleItem(item, callback); 544 | return; 545 | } 546 | } 547 | if(this.desc.readMultipleItems) { 548 | var items = this._popReadableItem(true); 549 | if(items.length === 1 && this.desc.readSingleItem) { 550 | this._requestReadSingleItem(items[0], callback); 551 | return; 552 | } else if(items.length > 0) { 553 | this._requestReadMultipleItems(items, callback); 554 | return; 555 | } 556 | } 557 | if(this.desc.readSingleItem) { 558 | var item = this._popReadableItem(false); 559 | if(item) { 560 | this._requestReadSingleItem(item, callback); 561 | return; 562 | } 563 | } 564 | this.requesting = false; 565 | callback(); 566 | }; 567 | 568 | ItemsStore.prototype.setItemError = function(id, newError) { 569 | var item = this.items["$" + id]; 570 | if(!item) { 571 | item = this._createItem(); 572 | item.data = this.desc.applyNewError(undefined, newError); 573 | item.error = newError; 574 | item.tick = this.updateTick; 575 | this.items["$" + id] = item; 576 | return; 577 | } 578 | newData = this.desc.applyNewError(item.data, newError); 579 | item.error = newError; 580 | this._setItemNewData(id, item, newData) 581 | }; 582 | 583 | ItemsStore.prototype.setItemData = function(id, newData) { 584 | var item = this.items["$" + id]; 585 | if(!item) { 586 | item = this._createItem(); 587 | item.data = this.desc.applyNewData(undefined, newData); 588 | item.tick = this.updateTick; 589 | this.items["$" + id] = item; 590 | return; 591 | } 592 | newData = this.desc.applyNewData(item.data, newData); 593 | item.error = null; 594 | this._setItemNewData(id, item, newData) 595 | }; 596 | 597 | ItemsStore.prototype._setItemNewData = function(id, item, newData) { 598 | if(item.newData !== undefined) { 599 | item.update = this.desc.rebaseUpdate(item.update, item.data, newData); 600 | item.newData = this.desc.applyUpdate(newData, item.update); 601 | } 602 | var oldData = item.data; 603 | item.data = newData; 604 | item.outdated = false; 605 | item.tick = this.updateTick; 606 | if(item.update === undefined) { 607 | var idx = this.invalidItems.indexOf(id); 608 | if(idx >= 0) 609 | this.invalidItems.splice(idx, 1); 610 | } 611 | var infoHandlers = item.infoHandlers && item.infoHandlers.slice(); 612 | var handlers = item.handlers && item.handlers.slice(); 613 | if(infoHandlers) { 614 | infoHandlers.forEach(function(fn) { 615 | fn(); 616 | }); 617 | } 618 | if(handlers && oldData !== newData) { 619 | handlers.forEach(function(fn) { 620 | fn(item.newData !== undefined ? item.newData : newData); 621 | }); 622 | } 623 | }; 624 | 625 | ItemsStore.prototype._popCreateableItem = function(multiple) { 626 | if(multiple) { 627 | if(this.maxCreateItems && this.maxCreateItems < this.createableItems.length) { 628 | return this.createableItems.splice(0, this.maxCreateItems); 629 | } else { 630 | var items = this.createableItems; 631 | this.createableItems = []; 632 | return items; 633 | } 634 | } else { 635 | return this.createableItems.shift(); 636 | } 637 | }; 638 | 639 | ItemsStore.prototype._popDeleteableItem = function(multiple) { 640 | if(multiple) { 641 | if(this.maxDeleteItems && this.maxDeleteItems < this.deletableItems.length) { 642 | return this.deletableItems.splice(0, this.maxDeleteItems); 643 | } else { 644 | var items = this.deletableItems; 645 | this.deletableItems = []; 646 | return items; 647 | } 648 | } else { 649 | return this.deletableItems.shift(); 650 | } 651 | }; 652 | 653 | ItemsStore.prototype._popWriteableItem = function(multiple, willRead) { 654 | var results = []; 655 | for(var i = 0; i < this.invalidItems.length; i++) { 656 | var id = this.invalidItems[i]; 657 | var item = this.items["$" + id]; 658 | if(item.update) { 659 | var result = { 660 | id: id, 661 | update: item.update, 662 | oldData: item.data, 663 | newData: item.newData 664 | }; 665 | item.outdated = true; 666 | item.data = item.newData; 667 | delete item.update; 668 | delete item.newData; 669 | if(willRead) { 670 | this.invalidItems.splice(i, 1); 671 | i--; 672 | } 673 | if(!multiple) 674 | return result; 675 | results.push(result); 676 | if(this.desc.maxWriteItems && results.length >= this.desc.maxWriteItems) 677 | break; 678 | } 679 | } 680 | if(multiple) 681 | return results; 682 | }; 683 | 684 | ItemsStore.prototype._popReadableItem = function(multiple) { 685 | var results = []; 686 | for(var i = 0; i < this.invalidItems.length; i++) { 687 | var id = this.invalidItems[i]; 688 | var item = this.items["$" + id]; 689 | if(item.outdated) { 690 | var result = { 691 | id: id, 692 | oldData: item.data 693 | }; 694 | this.invalidItems.splice(i, 1); 695 | i--; 696 | if(!multiple) 697 | return result; 698 | results.push(result); 699 | if(this.desc.maxReadItems && results.length >= this.desc.maxReadItems) 700 | break; 701 | } 702 | } 703 | if(multiple) 704 | return results; 705 | }; 706 | 707 | 708 | function applyUpdate(data, update) { 709 | return Object.assign({}, data, update); 710 | } 711 | 712 | function mergeUpdates(a, b) { 713 | return Object.assign({}, a, b); 714 | } 715 | 716 | function rebaseUpdate(update, oldData, newData) { 717 | return update; 718 | } 719 | 720 | function applyNewData(oldData, newData) { 721 | return newData; 722 | } 723 | 724 | function applyNewError(oldData, newError) { 725 | return null; 726 | } 727 | -------------------------------------------------------------------------------- /ItemsStoreFetcher.js: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License http://www.opensource.org/licenses/mit-license.php 3 | Author Tobias Koppers @sokra 4 | */ 5 | var ItemsStoreFetcher = module.exports = exports; 6 | 7 | ItemsStoreFetcher.fetch = function(fn, callback) { 8 | var ident = this.ident; 9 | var unavailableItems; 10 | function onItemAvailable() { 11 | if(--unavailableItems === 0) 12 | runFn(); 13 | } 14 | function listenTo(Store, id) { 15 | if(!Store.isItemUpToDate(id)) { 16 | unavailableItems++; 17 | Store.waitForItem(id, onItemAvailable); 18 | } 19 | } 20 | function runFn() { 21 | unavailableItems = 1; 22 | try { 23 | var ret = fn(listenTo); 24 | } catch(e) { 25 | unavailableItems = NaN; 26 | callback(e); 27 | } 28 | if(--unavailableItems === 0) { 29 | callback(null, ret); 30 | } 31 | } 32 | runFn(); 33 | }; 34 | -------------------------------------------------------------------------------- /ItemsStoreLease.js: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License http://www.opensource.org/licenses/mit-license.php 3 | Author Tobias Koppers @sokra 4 | */ 5 | function ItemsStoreLease() { 6 | this.leases = undefined; 7 | } 8 | 9 | module.exports = ItemsStoreLease; 10 | 11 | ItemsStoreLease.prototype.capture = function(fn, onUpdate) { 12 | var newLeases = []; 13 | var leases = this.leases; 14 | function listenTo(Store, id) { 15 | var lease = Store.listenToItem(id, onUpdate); 16 | var idx = newLeases.indexOf(lease); 17 | if(idx < 0) { 18 | if(leases) { 19 | idx = leases.indexOf(lease); 20 | if(idx >= 0) 21 | leases.splice(idx, 1); 22 | } 23 | newLeases.push(lease); 24 | } 25 | } 26 | var error = null; 27 | try { 28 | var ret = fn(listenTo); 29 | } catch(e) { 30 | error = e; 31 | } 32 | if(leases) { 33 | leases.forEach(function(lease) { 34 | lease.close(); 35 | }); 36 | } 37 | this.leases = newLeases; 38 | if(error) throw error; 39 | return ret; 40 | }; 41 | 42 | ItemsStoreLease.prototype.close = function() { 43 | if(this.leases) { 44 | this.leases.forEach(function(lease) { 45 | lease.close(); 46 | }); 47 | } 48 | this.leases = undefined; 49 | }; 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # items-store 2 | 3 | A simple flux-like architecture with a syncing items store. 4 | 5 | ![architecture](https://raw.githubusercontent.com/webpack/items-store/master/img/architecture.png) 6 | 7 | ## Idea 8 | 9 | ### ItemsStore 10 | 11 | A store that manages read and write access to items (accessed by a string id). 12 | 13 | It 14 | 15 | * offers synchronous access to items 16 | * caches items after reading 17 | * fires update events for items 18 | * merges multiple writes to items 19 | * let writes do optimistic updates 20 | 21 | ### ItemsStoreFetcher 22 | 23 | A helper which repeatedly calls a function until all references items are available. The ItemsStoreFetcher can fetch from multiple stores. 24 | 25 | ### ItemsStoreLease 26 | 27 | A helper class which leases multiple items from multiple stores. It captures dependencies from calling a function. 28 | 29 | ### createContainer 30 | 31 | A wrapper for a React component. It expects a static `getProps` method from the component that calculate `props` from stores (and params when using react-router). The wrapper container handles listening to changes and charging of stores. 32 | 33 | The usable API inside the `getProps` method is very simple and synchronous. See API > createContainer. 34 | 35 | The container exposes a static `chargeStores` method to charge the stores. 36 | 37 | 38 | ## API 39 | 40 | ### `ItemsStore` 41 | 42 | #### `new ItemsStore(desc, [initialData])` 43 | 44 | The constructor. 45 | 46 | `desc` A description of the store. The creator provides options and read/write methods that the store will use. 47 | 48 | `initialData` An object containing initial item data. You may pass the result of `getData` here. This should be used for initializing the stores after server-side rendering. 49 | 50 | #### `desc` 51 | 52 | The store description. The behavior of the store changes depending on the contained keys. Can contain these keys: 53 | 54 | **reading and writing** 55 | 56 | `readSingleItem: function(item, callback)` Reads a single item. `item` is an object `{ id: string, oldData: any|undefined }`. `callback` is a `function(err, newData: any|undefined)`. 57 | 58 | `readMultipleItems: function(items, callback)` Reads multiple items. Similar to `readSingleItem` but `items` is an array and the `callback` is a `function(err, newDatas: object)` where `newDatas` is an object containing items id as key (prefixed with any single char) and value is the new data. i. e. `{"_12345": { name: "item 12345" }}`. 59 | 60 | `writeSingleItem: function(item, callback)` Writes a single item. `item` is an object `{ id: string, update: any, oldData: any|undefined, newData: any }`. `callback` is a `function(err)`. 61 | 62 | `writeMultipleItems: function(items, callback)` Writes multiple items. Similar to `writeSingleItem` but `items` is an array. 63 | 64 | `createSingleItem: function(item, callback)` Creates a single item. `item` is an object `{ data: any }`. `callback` is a `function(err, newId)`. 65 | 66 | `createMultipleItems: function(items, callback)` Creates multiple items. Similar to `createSingleItem` but `items` is an array. 67 | 68 | `deleteSingleItem: function(item, callback)` Deletes a single item. `item` is an object `{ id: string }`. `callback` is a `function(err)`. 69 | 70 | `deleteMultipleItems: function(items, callback)` Deletes multiple items. Similar to `deleteSingleItem` but `items` is an array. 71 | 72 | `writeAndReadSingleItem: function(item, callback)` A combination of `writeSingleItem` followed by a `readSingleItem`. 73 | 74 | `writeAndReadMultipleItems: function(items, callback)` A combination of `writeMultipleItems` followed by a `readMultipleItems`. 75 | 76 | `createAndReadSingleItem: function(items, callback)` A combination of `createSingleItem` followed by a `readSingleItem`. `callback` is `function(err, newId, newData)`. 77 | 78 | `createAndReadMultipleItems: function(items, callback)` A combination of `createMultipleItems` followed by a `readMultipleItems`. `callback` is `function(err, newIds: array, newDatas: object)`. 79 | 80 | `maxCreateItems` Maximum of items allowed to be created by `createMultipleItems` or `createAndReadMultipleItems`. 81 | 82 | `maxWriteItems` Maximum of items allowed to be written by `writeMultipleItems` or `writeAndReadMultipleItems`. 83 | 84 | `maxDeleteItems` Maximum of items allowed to be delete by `deleteMultipleItems`. 85 | 86 | `maxReadItems` Maximum of items allowed to be read by `readMultipleItems`. 87 | 88 | You need to provide at least one read method. If you want to do updates you need to provide at least one write or writeAndRead method. 89 | 90 | Reading or writing multiple items is preferred if more than one items should be read or written. 91 | 92 | writeAndRead methods are preferred over write methods. 93 | 94 | If multiple requests are scheduled they are processed in this order: 1. create, 2. write, 3. delete, 3. read. 95 | 96 | **updates** 97 | 98 | `applyUpdate: function(data, update)` Apply an update to existing data. The new data is returned. Doesn't modify `data`. 99 | 100 | `applyUpdate` defaults to an method that merges (flat) the keys from `update` into `data`. 101 | 102 | `mergeUpdates: function(a, b)` Merges two update. A new update is returned that represents applying update `a` and `b`. 103 | 104 | `mergeUpdates` default to an flat merge. 105 | 106 | `rebaseUpdate: function(update, oldData, newData)` Called when new data is received while an item was already changed locally. Returns a new update that behaves to `newData` like `update` to `oldData`. 107 | 108 | `rebaseUpdate` default to an identity function that just returns `update`. 109 | 110 | `applyNewData: function(oldData, newData)` Apply new data (from `ItemsStore.setItemData`) to old data. The new data for the item is returned. Usually the function doesn't modify `oldData`, but this is not required by items-store (but react requires immutable props and state). A possible optimization is to return the `oldData` object when it is equal to `newData` (and do the same for nested objects). 111 | 112 | `applyNewData` defaults to an identity function that just returns `newData`. 113 | 114 | `applyNewError: function(oldData, newError)` Same as `applyNewData`, but for `ItemsStore.setItemError`. The new data for the item is returned. Usually the function returns same kind of marker data that signals an error to readers. The function should not modify `oldData`, but it can copy it to the error marker to display cached data in cause of an error. 115 | 116 | `applyNewError` defaults to a function that returns `null`. 117 | 118 | **timing** 119 | 120 | `queueRequest: function(fn)` Called when the store want to do something. It's called with a async function `fn(callback)` that should be called sometime in the future. You should wait at least one tick before calling `fn` if you want multiple reads/writes to be merged. You can use a shared queue to queue from multiple stores. 121 | 122 | Defaults to `process.nextTick`. 123 | 124 | #### `getItem(id)` 125 | 126 | Returns the current data of the item `id`. Returns `undefined` if no data is available. May return outdated cached data. 127 | 128 | #### `getItemInfo(id)` 129 | 130 | Returns status information about the item `id`. Returns an object with these keys: 131 | 132 | `available` Any data is available. 133 | 134 | `outdated` The item is outdated and a read is queued or will be queue when the item is read. 135 | 136 | `updated` The item was changed and a write is queued. 137 | 138 | `listening` Somebody is interested in this item. 139 | 140 | #### `isItemAvailable(id)` 141 | 142 | Returns `true` if any data is available. 143 | 144 | #### `isItemUpToDate(id)` 145 | 146 | Returns `true` if data is available and not outdated. 147 | 148 | #### `listenToItem(id, handler)` 149 | 150 | Listen to changes of the item `id`. `handler` is called with the new data. A lease is returned which has a single method `close` which stops listening. 151 | 152 | When calling `listenToItem` twice with the same `id` and `handler` no new lease is created. Instead the old lease is returned. 153 | 154 | Calling this method may trigger a read to the item. 155 | 156 | #### `waitForItem(id, callback)` 157 | 158 | Waits until the item `id` is up to date and calls the `callback` once it's up to date. 159 | 160 | Calling this method may trigger a read to the item. 161 | 162 | #### `getData()` 163 | 164 | Returns an object containing the data for every available item. 165 | 166 | #### `updateItem(id, update)` 167 | 168 | Applies the `update` to item `id`. The format of `update` depends on the provided `applyUpdate` implementation. 169 | 170 | Calling this method trigger a write to the item. 171 | 172 | #### `createItem(data, [callback])` 173 | 174 | Triggers a server request to create a new item. `callback` is called with the server response. 175 | 176 | #### `deleteItem(id, [callback])` 177 | 178 | Triggers a server request to delete an item. `callback` is called with the server response. 179 | 180 | #### `outdate()` 181 | 182 | Defines all available items as outdated. 183 | 184 | #### `outdate(id)` 185 | 186 | Defines item `id` as outdated. 187 | 188 | #### `update([all])` 189 | 190 | Defines all available items as outdated and 191 | 192 | * `all = false` (default): triggers reads for items which are listened. 193 | * `all = true`: triggers reads for all items 194 | 195 | #### `update(id)` 196 | 197 | Defines item `id` as outdated and triggers a read. 198 | 199 | #### `setItemData(id, newData)` 200 | 201 | Sets the current item data `newData` for the item `id`. Should only be called when receiving data from a higher instance i. e. from the server or database. 202 | 203 | You can use it in provided read and write methods when getting more information that the requested one. You should use this method when receiving data from an open stream. 204 | 205 | 206 | ### `ItemsStoreFetcher` 207 | 208 | #### static `fetch(fn, callback)` 209 | 210 | Calls `fn` (`function(addDependency)`) multiple times until all referenced items are available. Than calls `callback` (`function(err, result)`) with the return value. 211 | 212 | If `fn` throws an error `callback` is called immediately with the error. 213 | 214 | The provided function `addDependency(Store, id)` tell the fetcher that the `fn` used `Store` to read an item `id`. You must call it for each item read. You must not write to stores. 215 | 216 | 217 | ### `ItemsStoreLease` 218 | 219 | #### `new ItemsStoreLease()` 220 | 221 | Create a new instance. 222 | 223 | #### `capture(fn, onUpdate)` 224 | 225 | Calls `fn` (`function(addDependency)`) and starts listening to item updates (if not already listening). `onUpdate` is called when an item was updated. Calling this method also stops listening to items that are no longer referenced by the `fn`. 226 | 227 | The provided function `addDependency(Store, id)` tell the fetcher that the `fn` used `Store` to read an item `id`. You must call it for each item read. You must not write to stores. 228 | 229 | #### `close()` 230 | 231 | Stops listening to item updates. 232 | 233 | 234 | ### `createContainer` 235 | 236 | Creates a wrapper react component which handles store access and data listening. 237 | 238 | It uses update batching of react, so you **must** ensure that all calls to `ItemsStore.setItemData` and callbacks of `read...` are inside the react event system, batched with `ReactUpdates.batchedUpdates` or use a continuous batching strategy (i. e. `ReactRAFBatchingStrategy`). 239 | 240 | **component** 241 | 242 | It's expected from the component to provide a static `getProps` function. 243 | 244 | The context of the component must contain a key `stores` which is an object containing all stores (i. e. `{Messages: [ItemsStore], Users: [ItemsStore]}`). 245 | 246 | #### static `getProps(stores, params)` 247 | 248 | This function should create the component `props` from `stores` and `params` and return it. 249 | 250 | `stores` is an object containing a dependency-tracking version of each store i. e. 251 | ``` 252 | { 253 | Messages: { 254 | getItem: [Function], 255 | getItemInfo: [Function], 256 | isItemAvailable: [Function], 257 | isItemUpToDate: [Function] 258 | }, 259 | Users: { 260 | getItem: [Function], 261 | getItemInfo: [Function], 262 | isItemAvailable: [Function], 263 | isItemUpToDate: [Function] 264 | } 265 | } 266 | ``` 267 | 268 | `params` is the params object from `react-router` (if used) 269 | 270 | Example for a `getProps` method: 271 | 272 | ``` javascript 273 | statics: { 274 | getProps: function(stores, params) { 275 | if(!stores.Threads.isItemAvailable(params.threadId)) 276 | return { loading: true }; 277 | var thread = stores.Threads.getItem(params.threadId); 278 | return { 279 | thread: thread, 280 | messages: thread.messages.map(function(messageId) { 281 | var message = stores.Messages.getItem(messageId); 282 | return message && Object.assign({}, message, { 283 | user: stores.Users.getItem(message.userId) 284 | }); 285 | }) 286 | } 287 | } 288 | } 289 | ``` 290 | 291 | 292 | **wrapper methods** 293 | 294 | #### static `chargeStores(stores, params, callback)` 295 | 296 | Prepares stores with an `ItemsStoreFetcher`. 297 | 298 | `stores` The object of `ItemsStores`, like the `stores` key in the context. 299 | 300 | `params` params object from `react-router`. 301 | 302 | 303 | 304 | ### `Actions` 305 | 306 | Helpers to create actions: 307 | 308 | ``` javascript 309 | { [Function trigger] 310 | listen: [Function listen] 311 | } 312 | ``` 313 | 314 | An action can be triggered by calling it. Any number of arguments can be provided. 315 | 316 | An action has a `listen` (`function(callback, bindContext)`) method to listen to the action. 317 | 318 | ``` javascript 319 | var singleAction = Actions.create(); 320 | singleAction(); // trigger 321 | singleAction(1, 2, "hello"); // trigger with actions 322 | singleAction.listen(function(a, b, c) { 323 | console.log(a, b, c); 324 | }, this); 325 | 326 | var actions = Actions.create([ 327 | "someAction", 328 | "otherAction" 329 | ]); 330 | actions.someAction(); 331 | actions.otherAction("other"); 332 | ``` 333 | 334 | #### `create([names])` 335 | 336 | Creates a single action (without `names` parameter) or multiple actions (with `names` (`string[]`) parameter). 337 | 338 | 339 | ## Example 340 | 341 | https://github.com/webpack/react-starter 342 | 343 | 344 | ## TODO 345 | 346 | * `readAndStreamSingleItem`, `readAndStreamMultipleItems` 347 | * Timeout for cached data 348 | * Maximum size of cached data 349 | 350 | 351 | ## License 352 | 353 | Copyright (c) 2014-2015 Tobias Koppers [![Gittip donate button](http://img.shields.io/gittip/sokra.png)](https://www.gittip.com/sokra/) 354 | 355 | MIT (http://www.opensource.org/licenses/mit-license.php) 356 | 357 | -------------------------------------------------------------------------------- /createContainer.js: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License http://www.opensource.org/licenses/mit-license.php 3 | Author Tobias Koppers @sokra 4 | */ 5 | var React = require("react"); 6 | var ItemsStoreLease = require("./ItemsStoreLease"); 7 | var ItemsStoreFetcher = require("./ItemsStoreFetcher"); 8 | var ReactUpdates = require("react/lib/ReactUpdates"); 9 | 10 | function makeStores(stores, addDependency) { 11 | if(!addDependency) { 12 | return stores; 13 | } 14 | return Object.keys(stores).reduce(function(obj, key) { 15 | obj[key] = { 16 | getItem: function(id) { 17 | addDependency(stores[key], id); 18 | return stores[key].getItem(id); 19 | }, 20 | getItemInfo: function(id) { 21 | addDependency(stores[key], id); 22 | return stores[key].getItemInfo(id); 23 | }, 24 | isItemAvailable: function(id) { 25 | addDependency(stores[key], id); 26 | return stores[key].isItemAvailable(id); 27 | }, 28 | isItemUpToDate: function(id) { 29 | addDependency(stores[key], id); 30 | return stores[key].isItemUpToDate(id); 31 | }, 32 | }; 33 | return obj; 34 | }, {}); 35 | } 36 | 37 | module.exports = function createContainer(Component) { 38 | var componentName = Component.displayName || Component.name; 39 | if(!Component.getProps) 40 | throw new Error("Passed Component " + componentName + " has no static getProps function"); 41 | return React.createClass({ 42 | displayName: componentName + "Container", 43 | statics: { 44 | chargeStores: function(stores, params, callback) { 45 | ItemsStoreFetcher.fetch(function(addDependency) { 46 | Component.getProps(makeStores(stores, addDependency), params); 47 | }.bind(this), callback); 48 | } 49 | }, 50 | getInitialState: function() { 51 | if(!this.lease) this.lease = new ItemsStoreLease(); 52 | var stores = this.context.stores; 53 | var router = this.context.router; 54 | var params = router && router.getCurrentParams && router.getCurrentParams(); 55 | return this.lease.capture(function(addDependency) { 56 | return Component.getProps(makeStores(stores, addDependency), params); 57 | }, this._onUpdate); 58 | }, 59 | _onUpdate: function() { 60 | // _onUpdate is called when any leased value has changed 61 | // we schedule an update (this merges multiple changes to a single state change) 62 | if(this._updateScheduled) 63 | return; 64 | this._updateScheduled = true; 65 | ReactUpdates.asap(this._doUpdate); 66 | }, 67 | _doUpdate: function() { 68 | // 69 | this._updateScheduled = false; 70 | if(!this.isMounted()) return; 71 | var stores = this.context.stores; 72 | var router = this.context.router; 73 | var params = router && router.getCurrentParams && router.getCurrentParams(); 74 | this.setState(this.lease.capture(function(addDependency) { 75 | return Component.getProps(makeStores(stores, addDependency), params); 76 | }, this._onUpdate)); 77 | }, 78 | componentWillReceiveProps: function(newProps, newContext) { 79 | // on context change update, because params may have changed 80 | if(!newContext || !newContext.router) return; 81 | var stores = newContext.stores; 82 | var router = newContext.router; 83 | var params = router && router.getCurrentParams && router.getCurrentParams(); 84 | this.setState(this.lease.capture(function(addDependency) { 85 | return Component.getProps(makeStores(stores, addDependency), params); 86 | }, this._onUpdate)); 87 | }, 88 | componentWillUnmount: function() { 89 | if(this.lease) this.lease.close(); 90 | }, 91 | render: function() { 92 | return React.createElement(Component, this.state); 93 | }, 94 | contextTypes: { 95 | stores: React.PropTypes.object.isRequired, 96 | router: React.PropTypes.func 97 | } 98 | }) 99 | }; 100 | -------------------------------------------------------------------------------- /img/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sokra/items-store/1b62418b666e2ca7c8fba05ff6beea4cda62e1af/img/architecture.png -------------------------------------------------------------------------------- /img/architecture.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 27 | 33 | 34 | 41 | 47 | 48 | 49 | 71 | 82 | 83 | 85 | 86 | 88 | image/svg+xml 89 | 91 | 92 | 93 | 94 | 95 | 100 | 103 | 112 | Stores 123 | 124 | 127 | 136 | Views 147 | 148 | 151 | 160 | Actions 171 | 172 | 177 | 182 | 188 | 194 | 200 | 206 | updates 218 | data 230 | listen 242 | invoke 253 | write 265 | read 277 | 278 | 279 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License http://www.opensource.org/licenses/mit-license.php 3 | Author Tobias Koppers @sokra 4 | */ 5 | exports.ItemsStore = require("./ItemsStore"); 6 | exports.ItemsStoreLease = require("./ItemsStoreLease"); 7 | exports.ItemsStoreFetcher = require("./ItemsStoreFetcher"); 8 | exports.Actions = require("./Actions"); 9 | exports.createContainer = require("./createContainer"); 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "items-store", 3 | "version": "0.7.0", 4 | "description": "A flux-like architecture with a syncing items store", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "cover": "istanbul cover node_modules/mocha/bin/_mocha -- -R spec" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git@github.com:webpack/items-store.git" 13 | }, 14 | "keywords": [ 15 | "react", 16 | "flux", 17 | "store", 18 | "sync", 19 | "items" 20 | ], 21 | "author": "Tobias Koppers @sokra", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/webpack/items-store/issues" 25 | }, 26 | "homepage": "https://github.com/webpack/items-store", 27 | "peerDepenencies": { 28 | "react": "^0.13.0" 29 | }, 30 | "devDependencies": { 31 | "istanbul": "^0.3.5", 32 | "mocha": "^2.1.0", 33 | "should": "^4.6.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /test/ItemsStoreErrors.js: -------------------------------------------------------------------------------- 1 | var should = require("should"); 2 | var ItemsStore = require("../ItemsStore"); 3 | 4 | describe("ItemsStore Errors", function() { 5 | it("should be able handle setItemError", function() { 6 | var store = new ItemsStore({ 7 | applyNewError: function(oldData, error) { 8 | if(oldData === undefined) return error; 9 | return [oldData, error]; 10 | } 11 | }); 12 | store.setItemError("1", "err1"); 13 | store.setItemData("2", 2); 14 | store.setItemError("2", "err2"); 15 | store.getItem("1").should.be.eql("err1"); 16 | store.getItem("2").should.be.eql([2, "err2"]); 17 | store.getItemInfo("1").should.be.eql({ 18 | available: true, 19 | outdated: false, 20 | updated: false, 21 | listening: false, 22 | error: "err1" 23 | }); 24 | store.getItemInfo("2").should.be.eql({ 25 | available: true, 26 | outdated: false, 27 | updated: false, 28 | listening: false, 29 | error: "err2" 30 | }); 31 | }); 32 | it("should get error on item read", function(done) { 33 | var store = new ItemsStore({ 34 | readSingleItem: function(item, callback) { 35 | callback("err" + item.id); 36 | }, 37 | applyNewError: function(oldData, error) { 38 | return "error: " + error; 39 | } 40 | }); 41 | store.waitForItem("1", function() { 42 | store.getItem("1").should.be.eql("error: err1"); 43 | done(); 44 | }) 45 | }) 46 | it("should get error on item create", function(done) { 47 | var store = new ItemsStore({ 48 | createSingleItem: function(item, callback) { 49 | callback("err" + item.data); 50 | }, 51 | applyNewError: function(oldData, error) { 52 | return "error: " + error; 53 | } 54 | }); 55 | store.createItem("1", function(err) { 56 | err.should.be.eql("err1"); 57 | done(); 58 | }); 59 | }); 60 | it("should get error on item delete", function(done) { 61 | var store = new ItemsStore({ 62 | deleteSingleItem: function(item, callback) { 63 | callback("err" + item.id); 64 | }, 65 | applyNewError: function(oldData, error) { 66 | return "error: " + error; 67 | } 68 | }); 69 | store.deleteItem("1", function(err) { 70 | err.should.be.eql("err1"); 71 | done(); 72 | }); 73 | }); 74 | it("should get error on item write", function(done) { 75 | var store = new ItemsStore({ 76 | writeSingleItem: function(item, callback) { 77 | callback("err" + item.id + " " + item.update); 78 | }, 79 | readSingleItem: function(item, callback) { 80 | throw new Error("Should not be called"); 81 | }, 82 | applyNewError: function(oldData, error) { 83 | return "error: " + error; 84 | } 85 | }); 86 | store.updateItem("1", "new1"); 87 | store.waitForItem("1", function() { 88 | store.getItem("1").should.be.eql("error: err1 new1"); 89 | done(); 90 | }) 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /test/ItemsStoreFetcher.js: -------------------------------------------------------------------------------- 1 | var should = require("should"); 2 | var ItemsStore = require("../ItemsStore"); 3 | var ItemsStoreFetcher = require("../ItemsStoreFetcher"); 4 | 5 | describe("ItemsStoreFetcher", function() { 6 | it("should wait until item is available", function(done) { 7 | var store = new ItemsStore({ 8 | readSingleItem: function() {} 9 | }); 10 | var afterSet = false; 11 | ItemsStoreFetcher.fetch(function(addDependency) { 12 | addDependency(store, "2"); 13 | }, function(err) { 14 | if(err) throw err; 15 | afterSet.should.be.eql(true); 16 | done() 17 | }); 18 | store.setItemData("1", "d1"); 19 | afterSet = true; 20 | store.setItemData("2", "d2"); 21 | afterSet = false; 22 | }); 23 | it("should wait until items are available", function(done) { 24 | var store = new ItemsStore({ 25 | readSingleItem: function() {} 26 | }); 27 | var afterSet = false; 28 | ItemsStoreFetcher.fetch(function(addDependency) { 29 | addDependency(store, "2"); 30 | addDependency(store, "3"); 31 | addDependency(store, "4"); 32 | }, function(err) { 33 | if(err) throw err; 34 | afterSet.should.be.eql(true); 35 | done() 36 | }); 37 | store.setItemData("1", "d1"); 38 | store.setItemData("2", "d2"); 39 | store.setItemData("3", "d3"); 40 | afterSet = true; 41 | store.setItemError("4", "e4"); 42 | afterSet = false; 43 | }); 44 | it("should wait until more and more items are available", function(done) { 45 | var store = new ItemsStore({ 46 | readSingleItem: function() {} 47 | }); 48 | var afterSet = false; 49 | ItemsStoreFetcher.fetch(function(addDependency) { 50 | addDependency(store, "2"); 51 | if(!store.getItem("2")) return; 52 | addDependency(store, "3"); 53 | if(!store.getItem("3")) return; 54 | addDependency(store, "4"); 55 | }, function(err) { 56 | if(err) throw err; 57 | afterSet.should.be.eql(true); 58 | done() 59 | }); 60 | store.setItemData("1", "d1"); 61 | store.setItemData("2", "d2"); 62 | store.setItemData("3", "d3"); 63 | afterSet = true; 64 | store.setItemError("4", "e4"); 65 | afterSet = false; 66 | }); 67 | it("should wait until more and more items are available", function(done) { 68 | var counter = 0; 69 | var store = new ItemsStore({ 70 | readSingleItem: function(item, callback) { 71 | counter++; 72 | callback(null, "d" + item.id); 73 | } 74 | }); 75 | ItemsStoreFetcher.fetch(function(addDependency) { 76 | addDependency(store, "2"); 77 | if(!store.getItem("2")) return; 78 | addDependency(store, "3"); 79 | if(!store.getItem("3")) return; 80 | addDependency(store, "4"); 81 | if(!store.getItem("4")) return; 82 | }, function(err) { 83 | if(err) throw err; 84 | counter.should.be.eql(3); 85 | done() 86 | }); 87 | }); 88 | it("should not wait when items are already available", function() { 89 | var store = new ItemsStore({ 90 | readSingleItem: function() { 91 | throw new Error("should not be called"); 92 | } 93 | }); 94 | store.setItemData("1", "d1"); 95 | store.setItemData("2", "d2"); 96 | store.setItemData("3", "d3"); 97 | store.setItemError("4", "e4"); 98 | var called = 0; 99 | ItemsStoreFetcher.fetch(function(addDependency) { 100 | addDependency(store, "2"); 101 | addDependency(store, "3"); 102 | addDependency(store, "4"); 103 | }, function(err) { 104 | if(err) throw err; 105 | called++ 106 | }); 107 | called.should.be.eql(1); 108 | }); 109 | it("should not wait when items are already available (difficult values)", function() { 110 | var store = new ItemsStore({ 111 | readSingleItem: function() { 112 | throw new Error("should not be called"); 113 | } 114 | }); 115 | store.setItemData("1", null); 116 | store.setItemData("2", false); 117 | store.setItemData("3", 0); 118 | store.setItemError("4", null); 119 | var called = 0; 120 | ItemsStoreFetcher.fetch(function(addDependency) { 121 | addDependency(store, "2"); 122 | addDependency(store, "3"); 123 | addDependency(store, "4"); 124 | }, function(err) { 125 | if(err) throw err; 126 | called++ 127 | }); 128 | called.should.be.eql(1); 129 | }); 130 | it("should wait when only some items are already available", function() { 131 | var store = new ItemsStore({ 132 | readSingleItem: function() { 133 | throw new Error("should not be called"); 134 | } 135 | }); 136 | store.setItemData("1", "d1"); 137 | store.setItemData("2", "d2"); 138 | var called = 0; 139 | ItemsStoreFetcher.fetch(function(addDependency) { 140 | addDependency(store, "2"); 141 | addDependency(store, "3"); 142 | addDependency(store, "4"); 143 | }, function(err) { 144 | if(err) throw err; 145 | called++ 146 | }); 147 | called.should.be.eql(0); 148 | store.setItemData("3", "d3"); 149 | store.setItemError("4", "e4"); 150 | called.should.be.eql(1); 151 | }); 152 | it("should wait when items are outdated", function() { 153 | var store = new ItemsStore({ 154 | readSingleItem: function() { 155 | throw new Error("should not be called"); 156 | } 157 | }); 158 | store.setItemData("1", "d1"); 159 | store.setItemData("2", "d2"); 160 | store.setItemData("3", "d3"); 161 | store.setItemError("4", "e4"); 162 | store.outdate(); 163 | store.isItemUpToDate("1").should.be.eql(false); 164 | var called = 0; 165 | ItemsStoreFetcher.fetch(function(addDependency) { 166 | addDependency(store, "2"); 167 | addDependency(store, "3"); 168 | addDependency(store, "4"); 169 | }, function(err) { 170 | if(err) throw err; 171 | called++ 172 | }); 173 | called.should.be.eql(0); 174 | store.setItemData("1", "nd1"); 175 | store.setItemData("2", "nd2"); 176 | store.setItemData("3", "d3"); 177 | store.setItemError("4", "e4"); 178 | called.should.be.eql(1); 179 | }); 180 | }); 181 | -------------------------------------------------------------------------------- /test/ItemsStoreRead.js: -------------------------------------------------------------------------------- 1 | var should = require("should"); 2 | var ItemsStore = require("../ItemsStore"); 3 | 4 | describe("ItemsStore Read", function() { 5 | it("should be able handle setItemData", function() { 6 | var store = new ItemsStore({}); 7 | store.setItemData("1", "data1"); 8 | store.getItem("1").should.be.eql("data1"); 9 | (typeof store.getItem("2")).should.be.eql("undefined"); 10 | store.getItemInfo("1").should.containDeep({ 11 | available: true, 12 | outdated: false, 13 | updated: false, 14 | listening: false 15 | }); 16 | store.getItemInfo("2").should.containDeep({ 17 | available: false, 18 | outdated: false, 19 | updated: false, 20 | listening: false 21 | }); 22 | }); 23 | it("should run readSingleItem and applyNewData on read", function(done) { 24 | var store = new ItemsStore({ 25 | readSingleItem: function(item, callback) { 26 | callback(null, "data" + item.id); 27 | }, 28 | applyNewData: function(oldData, newData) { 29 | return oldData + "->" + newData; 30 | } 31 | }); 32 | store.waitForItem("1", function() { 33 | store.getItem("1").should.be.eql("undefined->data1"); 34 | done(); 35 | }) 36 | }); 37 | it("should run readSingleItem and applyNewData on repeated read (outdated)", function(done) { 38 | var counter = 1; 39 | var store = new ItemsStore({ 40 | readSingleItem: function(item, callback) { 41 | callback(null, (counter++) + "data" + item.id); 42 | }, 43 | applyNewData: function(oldData, newData) { 44 | return oldData + "->" + newData; 45 | } 46 | }); 47 | store.waitForItem("1", function() { 48 | store.getItem("1").should.be.eql("undefined->1data1"); 49 | store.outdate("1"); 50 | store.waitForItem("1", function() { 51 | store.getItem("1").should.be.eql("undefined->1data1->2data1"); 52 | done(); 53 | }); 54 | }); 55 | }); 56 | it("should not run readSingleItem and applyNewData on repeated read (not outdated)", function(done) { 57 | var counter = 1; 58 | var store = new ItemsStore({ 59 | readSingleItem: function(item, callback) { 60 | callback(null, (counter++) + "data" + item.id); 61 | }, 62 | applyNewData: function(oldData, newData) { 63 | return oldData + "->" + newData; 64 | } 65 | }); 66 | store.waitForItem("1", function() { 67 | store.getItem("1").should.be.eql("undefined->1data1"); 68 | store.waitForItem("1", function() { 69 | store.getItem("1").should.be.eql("undefined->1data1"); 70 | done(); 71 | }); 72 | }); 73 | }); 74 | it("should get items with getData", function() { 75 | var counter = 1; 76 | var store = new ItemsStore({}); 77 | store.setItemData("abc", "databc"); 78 | store.setItemData("def", "defata"); 79 | store.isItemAvailable("abc").should.be.eql(true); 80 | store.isItemAvailable("def").should.be.eql(true); 81 | store.isItemAvailable("ghi").should.be.eql(false); 82 | store.getData().should.be.eql({ 83 | $abc: "databc", 84 | $def: "defata" 85 | }); 86 | }); 87 | }); 88 | --------------------------------------------------------------------------------