new hcp and scroller implementation

This commit is contained in:
Mason Houtz
2021-04-13 20:11:53 -07:00
parent b7078f51fa
commit 8ffb41e8d6
75 changed files with 3899 additions and 2580 deletions
@@ -1,15 +1,8 @@
<!-- When a dataset collection is being viewed, this panel shows the contents of that collection -->
<template>
<DscProvider :is-root="isRoot" :collection="selectedCollection" v-slot="{ dsc }">
<CollectionContentProvider
v-if="dsc"
:parent="dsc"
v-slot="{
payload: { contents = [], startKey = null, topRows = 0, bottomRows = 0 },
handlers: { setScrollPos },
}"
>
<DscProvider :is-root="isRoot" :debounce-period="500" :collection="selectedCollection" v-slot="{ dsc }">
<CollectionContentProvider v-if="dsc" :parent="dsc" v-slot="{ loading, payload, setScrollPos }">
<ExpandedItems
:scope-key="selectedCollection.id"
:get-item-key="(item) => item.type_id"
@@ -25,13 +18,11 @@
</template>
<template v-slot:listing>
<!-- <pre>{{ payload }}</pre> -->
<Scroller
key-field="element_index"
:item-height="36"
:items="contents"
:scroll-to="startKey"
:top-placeholders="topRows"
:bottom-placeholders="bottomRows"
:class="{ loadingBackground: loading }"
v-bind="payload"
@scroll="setScrollPos"
>
<template v-slot="{ item, index }">
@@ -1,5 +1,5 @@
<template>
<CurrentUser v-slot="{ user }">
<CurrentUser class="d-flex flex-column" v-slot="{ user }">
<UserHistories v-if="user" :user="user" v-slot="{ currentHistory, histories, handlers }">
<HistoryPanel v-if="currentHistory" :history="currentHistory" v-on="handlers">
<template v-slot:nav>
+37 -17
View File
@@ -1,12 +1,11 @@
<template>
<HistoryContentProvider
:parent="history"
v-slot="{
loading,
params,
payload: { contents = [], startKey = null, topRows = 0, bottomRows = 0, totalMatches = 0 },
handlers: { updateParams, manualReload, setScrollPos },
}"
:params="params"
:disable-poll="false"
:debug="false"
:debounce-period="500"
v-slot="{ loading, payload, manualReload, setScrollPos }"
>
<ExpandedItems
:scope-key="history.id"
@@ -43,32 +42,38 @@
<ContentOperations
v-if="!history.empty"
:history="history"
:total-matches="totalMatches"
:total-matches="payload.totalMatches"
:loading="loading"
:params="params"
@update:params="updateParams"
:params.sync="params"
:content-selection="selectedItems"
@update:content-selection="selectItems"
:show-selection="showSelection"
@update:show-selection="setShowSelection"
@resetSelection="resetSelection"
@selectAllContent="selectItems(contents)"
@selectAllContent="selectItems(payload.contents)"
@manualReload="manualReload"
/>
</template>
<template v-slot:listing>
<HistoryEmpty v-if="history.empty" class="m-2" />
<!-- <div v-else>
<input
type="range"
min="0.0"
max="1.0"
step="0.1"
:value="scrollPos.cursor"
@input="setScrollPos({ cursor: 1.0 * $event.srcElement.value })"
/>
<pre>{{ payload | reportPayload }}</pre>
</div> -->
<Scroller
v-else
:class="{ loadingBackground: loading }"
key-field="hid"
:item-height="36"
:items="contents"
:scroll-to="startKey"
:top-placeholders="topRows"
:bottom-placeholders="bottomRows"
v-bind="payload"
:debug="false"
@scroll="setScrollPos"
>
<template v-slot="{ item, index }">
@@ -96,7 +101,7 @@
</template>
<script>
import { History } from "./model";
import { History, SearchParams } from "./model";
import { HistoryContentProvider, ExpandedItems, SelectedItems } from "./providers";
import Layout from "./Layout";
import HistoryMessages from "./HistoryMessages";
@@ -107,7 +112,16 @@ import ToolHelpModal from "./ToolHelpModal";
import Scroller from "./Scroller";
import { HistoryContentItem } from "./ContentItem";
const reportPayload = (p) => {
const { contents = [], ...theRest } = p;
const hids = contents.map((o) => o.hid);
return { hids, ...theRest };
};
export default {
filters: {
reportPayload,
},
components: {
HistoryContentProvider,
Layout,
@@ -124,6 +138,12 @@ export default {
props: {
history: { type: History, required: true },
},
data() {
return {
params: new SearchParams(),
useItemSelection: false,
};
},
computed: {
historyId() {
return this.history.id;
+325
View File
@@ -0,0 +1,325 @@
<template>
<div ref="container" class="scrollContainer" @wheel.prevent="onWheel">
<!-- mousewheel moves up and down linearly, allows for fine-tuning of view by moving just a
few rows at a time -->
<div ref="listing" class="listing">
<ul>
<li v-for="(item, index) in itemWindow" :key="getItemKey(item, index)">
<slot :item="item" :index="index" :row-key="getItemKey(item, index)">
<pre>{{ item }}</pre>
</slot>
</li>
</ul>
</div>
<!-- big blank scroll region uses scroll bar to drag view to entirely new region, calculated
by visual height of the scroller knob --->
<div v-show="showScroller" ref="scrollSliderContainer" class="scrollSliderContainer" @scroll="onScroll">
<div ref="scrollSlider"></div>
</div>
</div>
</template>
<script>
import { debounce } from "lodash";
import { SearchParams } from "./model";
const clean = (o) => JSON.parse(JSON.stringify(o));
export default {
props: {
// field in the item data that represents the "primary" key, less for caching purposes than
// Vue re-rendering comparison purposes. Should be a unique value from the list of data, and
// is typically HID for history contents and element_index for collection contents
keyField: { type: String, required: true },
// A list of items to render in the immediate region of the scroll pos
contents: { type: Array, required: true },
// index from contents to start rendering, will be 0 for top of list,
// we provide more contents than are currently rendered so the user can quickly
// scroll outside the immediate window and still have a little data
startKeyIndex: { type: Number, default: 0 },
// known total matches for this list of content independent of pagination or
// view windowing concerns, the raw data row count
totalMatches: { type: Number, default: 0 },
// number of undelivered rows above contents
topRows: { type: Number, default: 0 },
bottomRows: { type: Number, default: 0 },
// Number of items from contents to render starting from the startKeyIndex
pageSize: { type: Number, default: SearchParams.pageSize },
// avoid an infinite loop when we visually adjust the scroller bar upon
// receiving new content by suppressing outgoing update events until the
// DOM update has finished.
suppressionPeriod: { type: Number, default: 250 },
// debug flag
debug: { type: Boolean, default: false },
},
data() {
return {
// manual change of start key index, happens when user moves mouse-wheel
manualStartIndex: null,
// show/hide the draggable scrollbar
showScroller: false,
};
},
computed: {
// The index to start rendering content at, can be manually adjusted with
// the mouse wheel, and must be updated when new contents come in
itemStartIndex() {
return this.manualStartIndex ?? this.startKeyIndex;
},
// The slice of contents to render right now, this is the actual data we loop over in the template
itemWindow() {
return this.contents.slice(this.itemStartIndex, this.itemStartIndex + this.pageSize);
},
// starting row index if we include the undelivered topRows, this is a
// number representing how far down we are in the complete data set
dataStartIndex() {
return this.itemStartIndex + this.topRows;
},
// The portion of the way down the total list as descrbed by the input props
cursor() {
return this.totalMatches > 0 ? this.dataStartIndex / this.totalMatches : 0;
},
// user switched key, this is the row that was switched to
manualStartKey() {
const newIdx = this.manualStartIndex;
return this.contents?.[newIdx]?.[this.keyField];
},
},
watch: {
// when contents are updated, reset manualStartIndex
// to pay attention to the passed in startKeyIndex instead
// of the manual value we set when the user moved the mouse wheel
contents() {
this.manualStartIndex = null;
},
// When user moves the mouse wheel request more data by row key, since
// we should have that value
manualStartKey(key, oldKey) {
if (key !== undefined && key !== oldKey) {
this.$emit("scroll", { key });
}
},
cursor(newVal) {
this.debouncedAdjustScrollTop(newVal);
},
},
created() {
// non-reactive data
this.suppressEvents = false;
this.suppressionTimeout = null;
this.debouncedUpdateCursor = debounce(this.updateCursor, 100);
this.debouncedAdjustScrollTop = debounce(this.adjustScrollTop, 100);
},
updated() {
this.checkScrollbars();
// this.reportProps("updated");
},
mounted() {
this.checkScrollbars();
},
methods: {
log(...args) {
if (this.debug) {
console.log("Scroller ->", ...args);
}
},
reportProps(label) {
const data = clean(this._data);
const props = clean(this._props);
console.group(label);
console.dir({ data, props });
console.groupEnd();
},
getItemKey(item) {
return item[this.keyField];
},
// move the list up and down with the mouse wheel incrementally
// shifts manual startIndex up and down
onWheel({ deltaY }) {
// no wheel if list too short to warrant a scroll bar
// let the browser figure that out
if (this.showScroller) {
if (deltaY > 0) this.wheelDown();
if (deltaY < 0) this.wheelUp();
}
},
wheelDown(n = 1) {
this.manualStartIndex = Math.min(this.itemStartIndex + n, this.totalMatches - 1);
},
wheelUp(n = 1) {
this.manualStartIndex = Math.max(0, this.itemStartIndex - n);
},
// move to whole new regions with the scrollbar, takes the percentage
// of the height of the fake placeholder box and turns that into a 0-1
// value which we send out on scrollPos
onScroll() {
this.debouncedUpdateCursor();
},
// determines cursor from scrollTop, need to wait until after render to calculate
// this stuff because it depends on DOM elements
updateCursor() {
if (this.suppressEvents) return;
const box = this.$refs.scrollSliderContainer;
const slider = this.$refs.scrollSlider;
this.log("updateCursor", slider.clientHeight, box.clientHeight, box.scrollTop);
const maxHeight = Math.abs(slider.clientHeight - box.clientHeight);
const cursor = maxHeight > 0 ? box.scrollTop / maxHeight : 0;
this.$emit("scroll", { cursor });
},
// adjust scrolltop from scrollPos prop, happens on a content load
// when the content window changes, may need to adjust the position
// of the scroll bar handle, but we don't want to retrigger a bunch
// of extraneous scroll events in the process.
adjustScrollTop(newCursor) {
const box = this.$refs.scrollSliderContainer;
const slider = this.$refs.scrollSlider;
if (!(box && slider)) return; // might be empty history panel
const maxHeight = slider.clientHeight - box.clientHeight;
// new scrolltop represented by new cursor
// scrolltop should represent the height of the first displayed
// row of the content in relation to the total number of rows in
// the result set
const newScrollTop = 1.0 * maxHeight * newCursor;
// existing scrolltop
const existingScrollTop = box.scrollTop;
// this is how far we need to adjust
// If we're off by more than fudge, schedule an event-free adjustment
// to make it visually look right
const fudge = this.getFirstRowHeight();
const adjustment = Math.abs(newScrollTop - existingScrollTop);
if (adjustment > fudge) {
this.log("adjustScrollTop", newCursor, newScrollTop, adjustment);
this.setScrollTop(newScrollTop);
}
},
getFirstRowHeight() {
const rowHeight = this.$refs?.listing.querySelector("ul > li")?.offsetHeight || 10;
return parseInt(rowHeight);
},
setScrollTop(newScrollTop) {
this.suppressEvents = true;
if (this.suppressionTimeout) {
clearTimeout(this.suppressionTimeout);
this.suppressionTimeout = null;
}
this.log("setScrollTop", newScrollTop, this.suppressionPeriod);
const box = this.$refs.scrollSliderContainer;
box.scrollTop = Math.floor(newScrollTop);
this.suppressionTimeout = setTimeout(() => {
this.log("unsupressing");
this.suppressEvents = false;
}, this.suppressionPeriod);
},
// Determine whether the scrollbar is showing on the actual list,
// this value is used to toggle the overlay scroller on/off
checkScrollbars() {
const underScrollWidth = this.scrollBarWidth("listing");
this.showScroller = underScrollWidth > 0 || this.topRows > 0;
},
scrollBarWidth(refName) {
const listing = this.$refs?.[refName];
if (!listing) return 0;
// offset width includes scrollbar, clientWidth does not
return listing.offsetWidth - listing.clientWidth;
},
},
};
</script>
<style lang="scss">
.scrollContainer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
.listing,
.scrollSliderContainer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.listing {
overflow-y: scroll;
z-index: 0;
&::-webkit-scrollbar,
&::-webkit-scrollbar-track,
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
.scrollSliderContainer {
left: unset;
width: 100px;
overflow-y: scroll;
z-index: 1;
> div {
height: 5000px;
}
}
ul {
list-style: none;
}
ul,
li {
padding: 0;
margin: 0;
}
}
</style>
@@ -1,13 +0,0 @@
/* a very simple directive that alerts parent when its height changes */
const doUpdate = (label) => (el, binding) => {
const handler = binding.value;
handler(el.clientHeight, binding.modifiers);
};
export const Resize = {
bind: doUpdate("bind"),
componentUpdated: doUpdate("componentUpdated"),
};
export default Resize;
@@ -1,37 +0,0 @@
function inserted(el, binding) {
const { self = false } = binding.modifiers || {};
const value = binding.value;
const options = (typeof value === "object" && value.options) || {
passive: true,
};
const handler = typeof value === "function" || "handleEvent" in value ? value : value.handler;
const target = self ? el : binding.arg ? document.querySelector(binding.arg) : window;
if (!target) return;
target.addEventListener("scroll", handler, options);
el._onScroll = {
handler,
options,
// Don't reference self
target: self ? undefined : target,
};
}
function unbind(el) {
if (!el._onScroll) return;
const { handler, target = el } = el._onScroll;
target.removeEventListener("scroll", handler);
delete el._onScroll;
}
export const Scroll = {
inserted,
unbind,
};
export default Scroll;
@@ -1,239 +0,0 @@
import inViewport from "in-viewport";
import Scroll from "./Scroll";
import { cssLength, notNegative } from "./util";
import "./scroller.scss";
export default {
directives: { Scroll },
props: {
keyField: { type: String, required: true },
scrollDebounce: { type: Number, required: false, default: 250 },
itemHeight: { required: true, validator: notNegative },
items: { type: Array, required: true },
topPlaceholders: { type: Number, default: 0 },
bottomPlaceholders: { type: Number, default: 0 },
suppressionPeriod: { type: Number, default: 250 },
scrollTo: { default: null },
},
data() {
return {
scrollTop: 0,
};
},
computed: {
contentStyle() {
return {
paddingTop: cssLength(this.topPlaceholders * this.itemHeight),
paddingBottom: cssLength(this.bottomPlaceholders * this.itemHeight),
};
},
cursor() {
if (this.scrollTop > 0) {
const height = this.getRefProp("content", "clientHeight", 0);
if (undefined !== height && height > 0) {
return (1.0 * this.scrollTop) / height;
}
}
return 0;
},
scrollEventPayload() {
const el = this.getFirstVisibleElement();
return {
cursor: this.cursor,
key: el?.dataset.key,
keyIndex: el?.dataset.index,
};
},
},
watch: {
scrollEventPayload(payload) {
if (!this._suppressScrollEvent) {
this.$emit("scroll", payload);
}
},
scrollTo(val) {
this.suppress();
this.$nextTick(() => {
const { scrollToMe, container } = this.$refs;
const doScroll =
scrollToMe && val && scrollToMe.dataset.key == val && !inViewport(scrollToMe, { container });
if (doScroll) {
scrollToMe.scrollIntoView(true);
}
setTimeout(() => this.unsuppress(), 100);
});
},
},
methods: {
onScroll() {
this.scrollTop = this.$refs.container.scrollTop;
},
//#region Dom Utils
getScrollTop(refName = "container") {
return this.getRefProp(refName, "scrollTop");
},
getRefProp(refName, propName, defaultVal = undefined) {
if (refName in this.$refs) {
if (propName in this.$refs[refName]) {
return this.$refs[refName][propName];
}
}
return defaultVal;
},
getRowElementByKey(key) {
if (this.$refs.contentlist) {
const list = Array.from(this.$refs.contentlist.children);
return list.find((el) => el.dataset.key == key);
}
return undefined;
},
getFirstVisibleElement() {
const { container, contentlist } = this.$refs;
let result = undefined;
if (container && contentlist) {
for (const row of contentlist.children) {
if (inViewport(row, { container })) {
result = row;
break;
}
}
}
return result;
},
//#endregion
//#region Event suppression
suppress() {
this._suppressScrollEvent = true;
},
unsuppress() {
if (this._suppressTimeoutId) {
clearTimeout(this._suppressTimeoutId);
this._suppressTimeoutId = null;
}
this._suppressTimeoutId = setTimeout(() => {
this._suppressScrollEvent = false;
}, this.suppressionPeriod);
},
//#endregion
//#region Rendering
renderList() {
return this.items.map(this.renderItem);
},
renderItem(item, index) {
const h = this.$createElement;
const key = item[this.keyField];
const slotChild = this.renderSlot("default", { key, index, item });
let ref = undefined;
let staticClass = "";
if (key == this.scrollTo) {
ref = "scrollToMe";
staticClass = "scrollToMe";
}
const attrs = {
// index considering the missing data rows reprsented by topPlaceholders
"data-index": index + this.topPlaceholders,
// index with respect to the passed items array
"item-index": index,
"data-key": key,
};
return h("li", { key, ref, staticClass, attrs }, slotChild);
},
renderSlot(name = "default", data, optional = false) {
if (this.$scopedSlots[name]) {
return this.$scopedSlots[name](data instanceof Function ? data() : data);
} else if (this.$slots[name] && (!data || optional)) {
return this.$slots[name];
}
return undefined;
},
//#endregion
//#region Debugging
report(label) {
console.groupCollapsed(label);
console.log("scrollTo", this.scrollTo);
console.log("items.length", this.items.length);
if (this.items.length > 0) {
const list = this.items;
console.log("items", `${list[0].hid} -> ${list[list.length - 1].hid}`);
}
console.log("topPlaceholders", this.topPlaceholders);
console.log("bottomPlaceholders", this.bottomPlaceholders);
console.groupEnd();
},
//#endregion
},
render(h) {
// content list
const list = h(
"ul",
{
ref: "contentlist",
},
this.renderList()
);
// contains all the content + padding
const content = h(
"div",
{
ref: "content",
staticClass: "scrollContent",
style: this.contentStyle,
attrs: {
"data-top-rows": this.topPlaceholders,
"data-bottom-rows": this.bottomPlaceholders,
},
},
[list]
);
// outer container, scrollTop lives here
const container = h(
"div",
{
ref: "container",
staticClass: "historyScroller",
directives: [
{
name: "scroll",
modifiers: { self: true },
value: this.onScroll,
},
],
},
[content]
);
return container;
},
};
@@ -1,97 +0,0 @@
/**
* Debugging tool for figuring out what changed to force Vue to re-render
*/
import diff from "deep-diff";
const Monitor = (vm) => {
const collect = (fields) => fields.reduce((acc, fName) => ({ ...acc, [fName]: vm[fName] }), {});
const verbs = { N: "Added", D: "Removed", E: "Edited", A: "Array Element" };
function reportDifferences(label, diffs = []) {
console.groupCollapsed(label, diffs.length);
for (const d of diffs) {
try {
const { kind, path, index, lhs, rhs, item } = d;
const prop = path.join(".");
if (kind == "A") {
console.log(verbs[kind], `${prop}[${index}]`, item.rhs);
} else {
console.log(verbs[kind], prop, lhs, rhs);
}
} catch (err) {
console.log("oops", err, d);
}
}
console.groupEnd();
}
return {
// what are we watching?
props: false,
data: false,
computed: false,
get active() {
return this.props || this.data || this.computed;
},
// last known values
lastStuff: {},
// changes
differences: undefined,
// set of property names to not bother tracking, handy for large
// arrays where we don't want every little change
ignoreProps: new Set(),
getVals() {
const computedVals = this.computed ? collect(Object.keys(vm._computedWatchers)) : {};
const dataVals = this.data ? collect(Object.keys(vm._data)) : {};
const propVals = this.props ? collect(Object.keys(vm._props)) : {};
const vals = { ...propVals, ...dataVals, ...computedVals };
for (const propName of this.ignoreProps) {
delete vals[propName];
}
return vals;
},
diff() {
if (!this.active) return;
this.differences = diff(this.lastStuff || {}, this.getVals());
},
store() {
if (!this.active) return;
this.lastStuff = this.getVals();
},
report() {
if (!this.active) return;
if (this.differences) {
reportDifferences("updated", this.differences);
this.differences = undefined;
}
},
ignore(field) {
this.ignoreProps.add(field);
},
};
};
export default {
beforeCreate() {
this._monitor = Monitor(this);
},
beforeUpdate() {
this._monitor.diff();
},
updated() {
this._monitor.store();
this._monitor.report();
},
};
@@ -1,8 +0,0 @@
/**
* Had to build a custom virtual scroller to suit our needs.
*/
import Scroller from "./Scroller";
export { Scroller };
export default Scroller;
@@ -1,30 +0,0 @@
.historyScroller {
position: absolute;
width: 100%;
height: 100%;
overflow: auto;
}
.historyScroller {
ul {
list-style: none;
}
ul,
li {
padding: 0;
margin: 0;
}
li.bench {
overflow: hidden;
}
.scrollContent {
transition: padding-top 0.5s;
transition: padding-bottom 0.5s;
}
// .scrollToMe > .content-item {
// background-color: orange !important;
// }
}
@@ -1,19 +0,0 @@
// dumb formatter
export function cssLength(str, unit = "px") {
if (str == null || str === "") {
return undefined;
} else if (isNaN(+str)) {
return String(str);
} else {
const val = Number(str);
return `${val}${unit}`;
}
}
// validator
export function notNegative(rawVal) {
const val = Number(rawVal);
if (isNaN(val)) return false;
if (val < 0) return false;
return true;
}
@@ -1,59 +0,0 @@
const watchChangeHandler = (label) => (newVal, oldVal) => {
if (newVal != oldVal) {
console.log(label, newVal);
}
};
export default {
watch: {
items: {
immediate: true,
handler: watchChangeHandler("PROP: items"),
},
itemHeight: {
immediate: true,
handler: watchChangeHandler("PROP: itemHeight"),
},
scrollStartKey: {
immediate: true,
handler: watchChangeHandler("PROP: scrollStartKey"),
},
topPlaceholders: {
immediate: true,
handler: watchChangeHandler("PROP: topPlaceholders"),
},
bottomPlaceholders: {
immediate: true,
handler: watchChangeHandler("PROP: bottomPlaceholders"),
},
scrollTop: {
immediate: true,
handler: watchChangeHandler("DATA: scrollTop"),
},
scrollerHeight: {
immediate: true,
handler: watchChangeHandler("DATA: scrollerHeight"),
},
topPlaceholderHeight: {
immediate: true,
handler: watchChangeHandler("COMPUTED: topPlaceholderHeight"),
},
bottomPlaceholderHeight: {
immediate: true,
handler: watchChangeHandler("COMPUTED: bottomPlaceholderHeight"),
},
contentHeight: {
immediate: true,
handler: watchChangeHandler("DATA: contentHeight"),
},
},
};
@@ -8,7 +8,6 @@
* instances.
*/
import { pipe } from "rxjs";
import { content$, dscContent$ } from "./db/observables";
import { monitorQuery } from "./db/monitorQuery";
@@ -16,17 +15,15 @@ export * from "./db/promises";
export { loadDscContent } from "./loadDscContent";
export { loadHistoryContents, clearHistoryDateStore } from "./loadHistoryContents";
export { monitorHistoryContent } from "./monitorHistoryContent";
export { monitorHistoryContent, monitorCollectionContent } from "./monitorHistoryContent";
export { wipeDatabase } from "./db/wipeDatabase";
// generic content query monitor
export const monitorContentQuery = (cfg = {}) => {
const monitorCfg = { db$: content$, ...cfg };
return pipe(monitorQuery(monitorCfg));
return monitorQuery({ db$: content$, ...cfg });
};
// generic collection content monitor
export const monitorDscQuery = (cfg = {}) => {
const monitorCfg = { db$: dscContent$, ...cfg };
return pipe(monitorQuery(monitorCfg));
return monitorQuery({ db$: dscContent$, ...cfg });
};
@@ -13,6 +13,7 @@ const {
monitorContentQuery,
monitorDscQuery,
monitorHistoryContent,
monitorCollectionContent,
loadHistoryContents,
loadDscContent,
pollHistory,
@@ -22,13 +23,15 @@ const {
expose({
configure,
// observables
// observable operators
monitorContentQuery: asObservable(monitorContentQuery),
monitorDscQuery: asObservable(monitorDscQuery),
monitorHistoryContent: asObservable(monitorHistoryContent),
monitorCollectionContent: asObservable(monitorCollectionContent),
loadHistoryContents: asObservable(loadHistoryContents),
loadDscContent: asObservable(loadDscContent),
pollHistory: asObservable(pollHistory),
// promise functions
...promises,
});
@@ -1,19 +1,10 @@
export {
// observables
monitorContentQuery,
monitorDscQuery,
monitorHistoryContent,
loadHistoryContents,
loadDscContent,
pollHistory,
// promises
cacheContent,
getCachedContent,
bulkCacheContent,
uncacheContent,
cacheCollectionContent,
getCachedCollectionContent,
bulkCacheDscContent,
// utility & debugging
wipeDatabase,
} from "../CacheApi";
// For testing, bypass the worker convrsions
// and wrap each function in a jest mock wrapper
const cacheapi = require("../CacheApi");
const mockedModule = Object.entries(cacheapi).reduce((mod, [fnName, orig]) => {
return { ...mod, [fnName]: jest.fn(orig) };
}, {});
module.exports = mockedModule;
@@ -1,6 +1,6 @@
import { pipe } from "rxjs";
import { mergeMap } from "rxjs/operators";
import { needs } from "../operators/needs";
import { show, needs } from "utils/observable";
/**
* Pouchdb-find as an operator
@@ -8,8 +8,11 @@ import { needs } from "../operators/needs";
*
* @param {Observable} db$ Observable pouchDb instance
*/
export const find = (db$) => {
export const find = (db$, cfg = {}) => {
const { label = "find", debug = false } = cfg;
return pipe(
show(debug, (request) => console.log(`${label} -> request`, request)),
needs(db$),
mergeMap(async (inputs) => {
const [request, db] = inputs;
@@ -33,6 +36,7 @@ export const find = (db$) => {
}
return docs;
})
}),
show(debug, (result) => console.log(`${label} -> result`, result))
);
};
@@ -1,19 +1,22 @@
import deepEqual from "deep-equal";
import { isObservable, partition, merge, concat, of, pipe } from "rxjs";
import { tap, map, switchMap, debounceTime, distinctUntilChanged, pluck, filter } from "rxjs/operators";
import { isObservable, concat, of } from "rxjs";
import { concatAll, map, switchMap, debounceTime, distinctUntilChanged, pluck, filter } from "rxjs/operators";
import { matchesSelector } from "pouchdb-selector-core";
import { find } from "./find";
import { changes } from "./changes";
import { show } from "utils/observable";
/**
* Turns a selector into a live cache result emitter.
* Turns a selector into a live cache result emitter. Emits objects with
* a document object and a match boolean flag ({ doc, match: true }) indicating
* whether or not this document matches the request$ selector
*
* @param {Observable} db$ Observable PouchDB instance
* @param {Observable} request$ Observable Pouchdb-find configuration
*/
// prettier-ignore
export const monitorQuery = (cfg = {}) => (request$) => {
const { db$ = null, inputDebounce = 0 } = cfg;
const { db$, inputDebounce = 0, debug = false, label } = cfg;
if (!isObservable(db$)) {
throw new Error("Please pass a pouch database observable to monitorQuery");
@@ -27,84 +30,27 @@ export const monitorQuery = (cfg = {}) => (request$) => {
return debouncedRequest$.pipe(
switchMap((request) => {
const { selector } = request;
const idSet = new Set();
const docMatch = doc => matchesSelector(doc, selector);
// do a search of the cache first
const initial$ = of(request).pipe(
find(db$),
tap((docs) => docs.forEach((doc) => idSet.add(doc._id))),
map((docs) => ({ action: ACTIONS.INITIAL, initialMatches: docs })),
find(db$, { debug, label }),
concatAll(),
map((doc) => ({ doc, match: true, initial: true })),
show(debug, (change) => console.log("monitorQuery: initial", change)),
);
// later changes
const changedDocs$ = db$.pipe(
changes(),
filter(update => update.doc !== undefined),
const updates$ = db$.pipe(
changes({ debug, label }),
pluck("doc"),
);
// split stream into insert/update/remove
const docMatch = doc => matchesSelector(doc, selector);
const [ inSet$, notInSet$ ] = partition(changedDocs$, doc => idSet.has(doc._id));
const [ updateEvt$, removeEvt$ ] = partition(inSet$, docMatch);
const insertEvt$ = notInSet$.pipe(filter(docMatch));
// merge all updates
const updates$ = merge(
updateEvt$.pipe(
tap(doc => idSet.add(doc._id)),
map(doc => ({ action: ACTIONS.UPDATE, doc }))
),
removeEvt$.pipe(
tap(doc => idSet.delete(doc._id)),
map(doc => ({ action: ACTIONS.REMOVE, doc }))
),
insertEvt$.pipe(
tap(doc => idSet.add(doc._id)),
map(doc => ({ action: ACTIONS.ADD, doc }))
),
filter(Boolean),
map((doc) => ({ doc, match: docMatch(doc), update: true })),
show(debug, (change) => console.log("monitorQuery: update", change)),
);
return concat(initial$, updates$);
})
}),
show(debug, (change) => console.log("monitorQuery", change)),
);
};
// prettier-ignore
export const singleUpdateResult = () => {
return pipe(
map(update => {
const { action, initialMatches = [], doc = null } = update;
// if no match, result is undefined
let result = null;
switch (action) {
case ACTIONS.INITIAL:
if (initialMatches.length) {
result = initialMatches[0];
}
break;
case ACTIONS.REMOVE:
result = null;
break;
case ACTIONS.UPDATE:
case ACTIONS.ADD:
if (doc) {
result = doc;
}
break;
}
return result;
})
)
};
export const ACTIONS = {
INITIAL: "INITIAL",
ADD: "ADD",
UPDATE: "UPDATE",
REMOVE: "REMOVE",
IGNORE: "IGNORE",
};
@@ -1,184 +1,209 @@
import { of, timer } from "rxjs";
import { take, takeUntil } from "rxjs/operators";
import { takeUntil } from "rxjs/operators";
import { ObserverSpy } from "@hirez_io/observer-spy";
import { wait } from "jest/helpers";
import { wipeDatabase } from "./wipeDatabase";
import { monitorQuery, ACTIONS } from "./monitorQuery";
import { monitorQuery } from "./monitorQuery";
import { content$, dscContent$ } from "./observables";
import {
cacheContent,
getCachedContent,
bulkCacheContent,
bulkCacheDscContent,
cacheCollectionContent,
getCachedCollectionContent,
} from "./promises";
import { cacheContent, cacheCollectionContent, bulkCacheContent, bulkCacheDscContent } from "./promises";
// test data
import historyContent from "../../test/json/historyContent.json";
import collectionContent from "../../test/json/collectionContent.json";
const monitorSpinUp = 100;
jest.mock("app");
jest.mock("../../caching");
afterEach(async () => await wipeDatabase());
const monitorSpinUp = 100;
const monitorSafetyTimeout = 800;
const pluckAll = (arr, prop) => arr.map((o) => o[prop]);
afterEach(wipeDatabase);
// prettier-ignore
describe("monitorQuery: history content", () => {
describe("initial results", () => {
const selector = { hid: { $gt: 160 } };
test("first monitor event has initial results reflecting existing matches in db", async () => {
const cachedContent = await bulkCacheContent(historyContent, true);
const cachedContentIds = new Set(pluckAll(cachedContent, "_id"));
let monitor$;
let spy;
beforeEach(async () => {
// create monitor with query selector hid > 160
monitor$ = of({ selector }).pipe(
// create new monitor with query selector hid > 160
const cutoffHid = 120;
const selector = { hid: { $gt: cutoffHid } };
const monitor$ = of({ selector }).pipe(
monitorQuery({ db$: content$ }),
take(1),
takeUntil(timer(2000)), // safeguard
takeUntil(timer(monitorSafetyTimeout)),
);
// insert content
await bulkCacheContent(historyContent);
// spy on output of observable;
spy = new ObserverSpy();
// // listen to observable, wait for complete
const spy = new ObserverSpy();
monitor$.subscribe(spy);
// wait for monitor to spin-up
await wait(monitorSpinUp);
});
test("first monitor event has initial results reflecting existing matches in db", async () => {
// wait for monitor to run out
await spy.onComplete();
// check initial matches
const { initialMatches } = spy.getFirstValue();
expect(initialMatches).toBeDefined();
expect(initialMatches.length).toBeGreaterThan(0);
initialMatches.forEach((doc) => {
expect(doc.hid).toBeGreaterThan(selector.hid.$gt);
// should get one emit out with just the intial matches
const emits = spy.getValues();
expect(spy.getValuesLength()).toBeGreaterThan(0);
expect(spy.getValuesLength()).toBeLessThanOrEqual(cachedContentIds.size);
emits.forEach(({ doc, match, initial }) => {
// we aren't changing anything, results should be initial matches
expect(match).toBeTruthy();
// initial results have an identifying flag
expect(initial).toBe(true);
// every doc should match the selector
expect(doc => doc.hid > cutoffHid).toBeTruthy();
// every doc should be in the initially cached items
expect(cachedContentIds.has(doc._id)).toBeTruthy();
});
});
});
describe("updates", () => {
const selector = { hid: { $gt: 160 } };
let monitor$;
let spy;
let insertResults;
test("INSERT: adding to cache after instantiation should emit a new doc", async () => {
beforeEach(async () => {
monitor$ = of({ selector }).pipe(
const cutoffHid = 50;
const selector = { hid: { $gt: cutoffHid } };
const monitor$ = of({ selector }).pipe(
monitorQuery({ db$: content$ }),
take(2),
takeUntil(timer(2000)), // safeguard
takeUntil(timer(monitorSafetyTimeout)),
);
// insert content
insertResults = await bulkCacheContent(historyContent);
// spy on output of observable;
spy = new ObserverSpy();
const spy = new ObserverSpy();
monitor$.subscribe(spy);
// wait for monitor to spin-up
// wait for the monitor to spin-up so the insert doesn't look like the initial vals
await wait(monitorSpinUp);
});
test("new insert matching selector should emit an ADD", async () => {
// copy document, insert copy with some new props
const id = insertResults[0].id;
// eslint-disable-next-line no-unused-vars
const { _id, _rev, ...newDoc } = await getCachedContent(id);
newDoc.hid = 10000;
newDoc.id = "234234234";
const cacheUpdate = await cacheContent(newDoc);
expect(cacheUpdate.updated).toBeTruthy();
// Insert a row, make sure it should match the selector
const insertedContent = await cacheContent(historyContent[0], true);
expect(insertedContent.hid > cutoffHid).toBeTruthy();
// wait for monitor to run out
// wait for observable end
await spy.onComplete();
// look for initial matches
const { initialMatches } = spy.getFirstValue();
expect(initialMatches).toBeDefined();
expect(initialMatches.length).toBeGreaterThan(0);
// make sure the updated element is NOT in the initial matches
const inInitialSet = initialMatches.filter((doc) => doc._id == cacheUpdate.id);
expect(inInitialSet.length).toEqual(0);
// check for add event
const { action, doc } = spy.getLastValue();
expect(action).toBeDefined();
expect(doc).toBeDefined();
expect(action).toEqual(ACTIONS.ADD);
expect(doc.hid).toEqual(newDoc.hid);
const emits = spy.getValues();
expect(spy.getValuesLength()).toEqual(1);
emits.forEach(({ doc, match, initial, update }) => {
expect(doc.hid > cutoffHid).toBeTruthy();
expect(match).toBe(true);
expect(initial).toBeUndefined();
expect(update).toBe(true);
})
});
test("update still covered by selector should emit an UPDATE", async () => {
// update existing doc
const id = insertResults[0].id;
const testDoc = await getCachedContent(id);
testDoc.foo = 123;
const cacheUpdate = await cacheContent(testDoc);
expect(cacheUpdate.updated).toBeTruthy();
test("UPDATE: updating a previously emitted doc should emit an update event", async () => {
const cutoffHid = 50;
// wait for monitor to run out
// Insert a row, make sure it should match the selector
const insertedContent = await cacheContent(historyContent[0], true);
expect(insertedContent.hid > cutoffHid).toBeTruthy();
// subscribe to listener
const selector = { hid: { $gt: cutoffHid } };
const monitor$ = of({ selector }).pipe(
monitorQuery({ db$: content$ }),
takeUntil(timer(monitorSafetyTimeout)),
);
const spy = new ObserverSpy();
monitor$.subscribe(spy);
// wait for the monitor to spin-up so the insert doesn't look like the initial vals
await wait(monitorSpinUp);
// update the content
const fooVal = 123;
insertedContent.foo = fooVal;
const updatedContent = await cacheContent(insertedContent, true);
expect(updatedContent._id).toEqual(insertedContent._id);
expect(updatedContent.foo).toEqual(fooVal);
// wait for observable end
await spy.onComplete();
expect(spy.getValuesLength()).toEqual(2); // initial insert + later update
// look for initial matches
const { initialMatches } = spy.getFirstValue();
expect(initialMatches).toBeDefined();
expect(initialMatches.length).toBeGreaterThan(0);
// check first event
const firstEvent = spy.getValueAt(0);
{
const { doc, match, initial, update } = firstEvent;
expect(doc.foo).toBeUndefined();
expect(match).toBe(true);
expect(initial).toBe(true);
expect(update).toBeUndefined();
}
// make sure the updated element is in the initial matches
const inInitialSet = initialMatches.filter((doc) => doc._id == cacheUpdate.id);
expect(inInitialSet.length).toEqual(1);
// check for update event
const { action, doc } = spy.getLastValue();
expect(action).toBeDefined();
expect(doc).toBeDefined();
expect(action).toEqual(ACTIONS.UPDATE);
expect(doc.foo).toEqual(testDoc.foo);
// check update event
const secondEvent = spy.getValueAt(1);
{
const { doc, match, initial, update } = secondEvent;
expect(doc.foo).toBe(fooVal);
expect(match).toBe(true);
expect(initial).toBeUndefined();
expect(update).toBe(true);
}
});
test("update no longer covered by selector should emit a REMOVE", async () => {
// update doc to be outside query
const id = insertResults[0].id;
const testDoc = await getCachedContent(id);
testDoc.hid = -3000;
const cacheUpdate = await cacheContent(testDoc);
expect(cacheUpdate.updated).toBeTruthy();
test("DELETE: cache update causes element to no longer be in selection", async () => {
const cutoffHid = 50;
// wait for monitor to run out
// Insert a row, make sure it should match the selector
const insertedContent = await cacheContent(historyContent[0], true);
expect(insertedContent.hid > cutoffHid).toBeTruthy();
// subscribe to listener
const selector = { hid: { $gt: cutoffHid } };
const monitor$ = of({ selector }).pipe(
monitorQuery({ db$: content$ }),
takeUntil(timer(monitorSafetyTimeout)),
);
const spy = new ObserverSpy();
monitor$.subscribe(spy);
// wait for the monitor to spin-up so the insert doesn't look like the initial vals
await wait(monitorSpinUp);
// update the content to be outside the selector
const badHid = -10000;
insertedContent.hid = badHid; // no longer > cutoffHid
const updatedContent = await cacheContent(insertedContent, true);
expect(updatedContent._id).toEqual(insertedContent._id);
expect(updatedContent.hid).toEqual(badHid);
// wait for observable end
await spy.onComplete();
expect(spy.getValuesLength()).toEqual(2); // initial insert + later update
// look for initial matches
const { initialMatches } = spy.getFirstValue();
expect(initialMatches).toBeDefined();
expect(initialMatches.length).toBeGreaterThan(0);
// check first event
const firstEvent = spy.getValueAt(0);
{
const { doc, match, initial, update } = firstEvent;
expect(doc).toBeDefined();
expect(doc).toBeInstanceOf(Object);
expect(doc.hid).not.toEqual(badHid);
expect(match).toBe(true);
expect(initial).toBe(true);
expect(update).toBeUndefined();
}
// make sure the updated element is in the initial matches
const inInitialSet = initialMatches.filter((doc) => doc._id == cacheUpdate.id);
expect(inInitialSet.length).toEqual(1);
// check for remove event
const { action, doc } = spy.getLastValue();
expect(action).toBeDefined();
expect(doc).toBeDefined();
expect(action).toEqual(ACTIONS.REMOVE);
expect(doc.hid).toEqual(testDoc.hid);
// check update event
const secondEvent = spy.getValueAt(1);
{
const { doc, match, initial, update } = secondEvent;
expect(doc).toBeDefined();
expect(doc).toBeInstanceOf(Object);
expect(doc.hid).toEqual(badHid);
expect(match).toBe(false); // no longer matches selector
expect(initial).toBeUndefined();
expect(update).toBe(true);
}
});
});
});
describe("monitorQuery: collection content", () => {
// pre-process raw collection content
// doctor and cache sample content
const fakeParentUrl = "/abc/def/ghi";
const testContent = collectionContent.map((doc) => {
doc.parent_url = fakeParentUrl;
@@ -186,167 +211,188 @@ describe("monitorQuery: collection content", () => {
});
describe("initial results", () => {
const selector = {
parent_url: fakeParentUrl,
element_index: { $gt: 1 },
};
test("first monitor event has initial results reflecting existing matches in db", async () => {
// insert some stuff
const cachedDscContent = await bulkCacheDscContent(testContent, true);
const cachedContentIds = new Set(pluckAll(cachedDscContent, "_id"));
let monitor$;
let spy;
beforeEach(async () => {
monitor$ = of({ selector }).pipe(
// create new monitor
const limitIndex = 1;
const selector = {
parent_url: fakeParentUrl,
element_index: { $gt: limitIndex },
};
const monitor$ = of({ selector }).pipe(
monitorQuery({ db$: dscContent$ }),
take(1),
takeUntil(timer(2000)) // safeguard
takeUntil(timer(monitorSafetyTimeout))
);
// insert content
await bulkCacheDscContent(testContent);
// spy on output of observable;
spy = new ObserverSpy();
// wait til done
const spy = new ObserverSpy();
monitor$.subscribe(spy);
// wait for monitor to spin-up
await wait(monitorSpinUp);
});
test("first monitor event has initial results reflecting existing matches in db", async () => {
// wait for monitor to run out
await spy.onComplete();
// check initial matches
const { initialMatches } = spy.getFirstValue();
expect(initialMatches).toBeDefined();
expect(initialMatches.length).toBeGreaterThan(0);
initialMatches.forEach((doc) => {
expect(doc.parent_url).toEqual(selector.parent_url);
expect(doc.element_index).toBeGreaterThan(selector.element_index.$gt);
// check emits
const emits = spy.getValues();
expect(spy.getValuesLength()).toBeGreaterThan(0);
expect(spy.getValuesLength()).toBeLessThanOrEqual(cachedContentIds.size);
emits.forEach(({ doc, match, initial, update }) => {
expect(match).toBe(true);
expect(initial).toBe(true);
expect(update).toBeUndefined();
expect(cachedContentIds.has(doc._id)).toBeTruthy();
expect(doc.element_index > limitIndex).toBeTruthy();
expect(doc.parent_url).toEqual(fakeParentUrl);
});
});
});
describe("updates", () => {
const selector = {
parent_url: fakeParentUrl,
name: { $regex: /M117/i },
};
let monitor$;
let insertResults;
let spy;
beforeEach(async () => {
monitor$ = of({ selector }).pipe(
test("INSERT: adding to cache after instantiation should emit a new doc", async () => {
// createa monitor
const limitIndex = 0;
const selector = {
parent_url: fakeParentUrl,
element_index: { $gt: limitIndex },
};
const monitor$ = of({ selector }).pipe(
monitorQuery({ db$: dscContent$ }),
take(2),
takeUntil(timer(2000)) // safeguard
takeUntil(timer(monitorSafetyTimeout))
);
insertResults = await bulkCacheDscContent(testContent);
spy = new ObserverSpy();
const spy = new ObserverSpy();
monitor$.subscribe(spy);
// wait for monitor to spin-up
// wait for the monitor to spin-up so the insert doesn't look like the initial vals
await wait(monitorSpinUp);
});
test("new insert matching selector should emit an ADD", async () => {
// insert copy of new doc
const newDoc = testContent[0];
newDoc.element_index = 10000;
const addResult = await cacheCollectionContent(newDoc);
expect(addResult.updated).toBeTruthy();
// Insert a row, make sure it should match the selector
const insertedContent = await cacheCollectionContent(testContent[3], true);
expect(insertedContent.element_index > limitIndex).toBeTruthy();
// check the change is in there
const { id: newId } = addResult;
const lookup = await getCachedCollectionContent(newId);
expect(lookup.element_index).toEqual(newDoc.element_index);
// wait for monitor to run out
// wait for observable end
await spy.onComplete();
expect(spy.receivedNext()).toBe(true);
expect(spy.receivedComplete()).toBe(true);
expect(spy.getValuesLength()).toBe(2);
// look for initial matches
const { initialMatches } = spy.getFirstValue();
expect(initialMatches).toBeDefined();
expect(initialMatches.length).toBeGreaterThan(0);
// make sure the insert element is NOT in the initial matches
const inInitialSet = initialMatches.filter((doc) => doc._id == addResult.id);
expect(inInitialSet.length).toEqual(0);
// look for add event
const { action, doc } = spy.getLastValue();
expect(action).toBeDefined();
expect(doc).toBeDefined();
expect(action).toEqual(ACTIONS.ADD);
expect(doc.element_index).toEqual(newDoc.element_index);
const emits = spy.getValues();
expect(spy.getValuesLength()).toEqual(1);
emits.forEach(({ doc, match, initial, update }) => {
expect(doc.element_index > limitIndex).toBeTruthy();
expect(doc._id).toEqual(insertedContent._id);
expect(match).toBe(true);
expect(initial).toBeUndefined();
expect(update).toBe(true);
});
});
test("update still covered by selector should emit an UPDATE", async () => {
// look up an existing doc
const id = insertResults[0].id;
const lookupDoc = await getCachedCollectionContent(id);
expect(lookupDoc).toBeDefined();
expect(lookupDoc._id).toEqual(id);
test("UPDATE: updating a previously emitted doc should emit an update event", async () => {
const limitIndex = 0;
// update it
lookupDoc.foo = "fakestuff";
const updateResult = await cacheCollectionContent(lookupDoc);
expect(updateResult.updated).toBeTruthy();
// Insert a row, make sure it should match the selector
const insertedContent = await cacheCollectionContent(testContent[1], true);
expect(insertedContent.element_index > limitIndex).toBeTruthy();
// wait for monitor to run out
// create monitor
const selector = {
parent_url: fakeParentUrl,
element_index: { $gt: limitIndex },
};
const monitor$ = of({ selector }).pipe(
monitorQuery({ db$: dscContent$ }),
takeUntil(timer(monitorSafetyTimeout))
);
const spy = new ObserverSpy();
monitor$.subscribe(spy);
// wait for the monitor to spin-up so the insert doesn't look like the initial vals
await wait(monitorSpinUp);
// update the content
const fooVal = 123;
insertedContent.foo = fooVal;
const updatedContent = await cacheCollectionContent(insertedContent, true);
expect(updatedContent._id).toEqual(insertedContent._id);
expect(updatedContent.foo).toEqual(fooVal);
// wait for observable end
await spy.onComplete();
expect(spy.getValuesLength()).toEqual(2); // initial insert + later update
// look for initial matches
const { initialMatches } = spy.getFirstValue();
expect(initialMatches).toBeDefined();
expect(initialMatches.length).toBeGreaterThan(0);
// check first event
const firstEvent = spy.getValueAt(0);
{
const { doc, match, initial, update } = firstEvent;
expect(doc.foo).toBeUndefined();
expect(match).toBe(true);
expect(initial).toBe(true);
expect(update).toBeUndefined();
}
// make sure the updated doc is in the initial matches
const inInitialSet = initialMatches.filter((doc) => doc._id == updateResult.id);
expect(inInitialSet.length).toEqual(1);
// look for add event
const { action, doc } = spy.getLastValue();
expect(action).toBeDefined();
expect(doc).toBeDefined();
expect(action).toEqual(ACTIONS.UPDATE);
expect(doc.foo).toEqual(lookupDoc.foo);
// check update event
const secondEvent = spy.getValueAt(1);
{
const { doc, match, initial, update } = secondEvent;
expect(doc.foo).toBe(fooVal);
expect(match).toBe(true);
expect(initial).toBeUndefined();
expect(update).toBe(true);
}
});
test("update no longer covered by selector should emit a REMOVE", async () => {
// set to deleted
const id = insertResults[0].id;
const testDoc = await getCachedCollectionContent(id);
testDoc.name = "ABC";
testDoc.element_identifier = "ABC";
const cacheUpdate = await cacheCollectionContent(testDoc);
expect(cacheUpdate.updated).toBeTruthy();
test("DELETE: cache update causes element to no longer be in selection", async () => {
const limitIndex = 0;
// wait for monitor to run out
// Insert a row, make sure it should match the selector
const insertedContent = await cacheCollectionContent(testContent[1], true);
expect(insertedContent.element_index > limitIndex).toBeTruthy();
// create monitor
const selector = {
parent_url: fakeParentUrl,
element_index: { $gt: limitIndex },
};
const monitor$ = of({ selector }).pipe(
monitorQuery({ db$: dscContent$ }),
takeUntil(timer(monitorSafetyTimeout))
);
const spy = new ObserverSpy();
monitor$.subscribe(spy);
// update the content to be outside the selector
const badIndex = -10000;
insertedContent.element_index = badIndex; // no longer > cutoffHid
const updatedContent = await cacheCollectionContent(insertedContent, true);
expect(updatedContent._id).toEqual(insertedContent._id);
expect(updatedContent.element_index).toEqual(badIndex);
// wait for observable end
await spy.onComplete();
expect(spy.getValuesLength()).toEqual(2); // initial insert + later update
// look for initial matches
const { initialMatches } = spy.getFirstValue();
expect(initialMatches).toBeDefined();
expect(initialMatches.length).toBeGreaterThan(0);
// check first event
const firstEvent = spy.getValueAt(0);
{
const { doc, match, initial, update } = firstEvent;
expect(doc).toBeDefined();
expect(doc).toBeInstanceOf(Object);
expect(doc.element_index).not.toEqual(badIndex);
expect(match).toBe(true);
expect(initial).toBe(true);
expect(update).toBeUndefined();
}
// make sure the updated element is in the initial matches
const inInitialSet = initialMatches.filter((doc) => doc._id == cacheUpdate.id);
expect(inInitialSet.length).toEqual(1);
// look for the remove event
const { action, doc } = spy.getLastValue();
expect(action).toBeDefined();
expect(doc).toBeDefined();
expect(action).toEqual(ACTIONS.REMOVE);
expect(doc.name).toEqual(testDoc.name);
expect(doc.element_identifier).toEqual(testDoc.element_identifier);
// check update event
const secondEvent = spy.getValueAt(1);
{
const { doc, match, initial, update } = secondEvent;
expect(doc).toBeDefined();
expect(doc).toBeInstanceOf(Object);
expect(doc.element_index).toEqual(badIndex);
expect(match).toBe(false); // no longer matches selector
expect(initial).toBeUndefined();
expect(update).toBe(true);
}
});
});
});
@@ -7,7 +7,7 @@ import moment from "moment";
import deepEqual from "deep-equal";
import { defer, pipe, from } from "rxjs";
import { tap, filter, mergeMap, reduce, shareReplay } from "rxjs/operators";
import { needs } from "../operators/needs";
import { needs } from "utils/observable";
import { dasherize } from "underscore.string";
import PouchDB from "pouchdb";
@@ -0,0 +1,9 @@
// seek types
const seeks = { ASC: "asc", DESC: "desc" };
seeks.isValid = (val) => {
return Object.values(SEEK).some((v) => v == val);
};
export const SEEK = seeks;
@@ -7,12 +7,14 @@ import { toPromise, toOperator } from "./workerClient";
export const monitorContentQuery = toOperator("monitorContentQuery");
export const monitorDscQuery = toOperator("monitorDscQuery");
export const monitorHistoryContent = toOperator("monitorHistoryContent");
export const monitorCollectionContent = toOperator("monitorCollectionContent");
/**
* Loaders
*/
export const loadHistoryContents = toOperator("loadHistoryContents");
export const loadDscContent = toOperator("loadDscContent");
export const collectionContentAjaxLoad = toOperator("collectionContentAjaxLoad");
/**
* Cache promise functions
@@ -3,70 +3,71 @@
* or filters data in the listing for immediate lookups. All of them will run
* one or more ajax calls against the api and cache ther results.
*/
import { map, withLatestFrom, pluck } from "rxjs/operators";
import { throttleDistinct } from "utils/observable/throttleDistinct";
import { nth } from "utils/observable/nth";
import { map, pluck, publish } from "rxjs/operators";
import { hydrate, nth, whenAny } from "utils/observable";
import { throttleDistinct } from "./operators/throttleDistinct";
import { requestWithUpdateTime } from "./operators/requestWithUpdateTime";
import { bulkCacheDscContent } from "./db";
import { prependPath } from "./workerConfig";
import { hydrate } from "./operators/hydrate";
import { chunkParam } from "./operators/chunk";
import { requestWithUpdateTime } from "./operators/requestWithUpdateTime";
import { summarizeCacheOperation, dateStore } from "./loadHistoryContents";
import { SearchParams } from "../model/SearchParams";
import { ScrollPos } from "../model/ScrollPos";
import { prependPath } from "./workerConfig";
import { summarizeCacheOperation, dateStore } from "./loadHistoryContents";
/**
* Load collection content (drill down)
* Params: contents_url + search params + element_index
*/
// prettier-ignore
export const loadDscContent = (cfg = {}) => (src$) => {
export const loadDscContent = (cfg = {}) => {
const {
onceEvery = 10 * 1000,
chunkSize = SearchParams.pageSize
} = cfg;
return publish(src$ => {
// clean and chunk inputs
const inputs$ = src$.pipe(
hydrate([undefined, SearchParams, ScrollPos]),
);
// clean and chunk inputs
const inputs$ = src$.pipe(
hydrate([undefined, SearchParams]),
chunkParam(2, chunkSize)
);
// add contents_url to cache data, will use it as a key
const url$ = inputs$.pipe(nth(0));
// add contents_url to cache data, will use it as a key
const url$ = inputs$.pipe(nth(0));
// request, throttle frequently repeated requests
const ajaxResponse$ = inputs$.pipe(
collectionContentAjaxLoad(cfg)
);
// request, throttle frequently repeated requests
const ajaxResponse$ = inputs$.pipe(
// Add in the url we found this content at because that is going to be like
// a parent_id in the database
const response$ = whenAny(ajaxResponse$, url$).pipe(
map(([response, url]) => response.map((row) => ({ ...row, parent_url: url })))
);
const cached$ = response$.pipe(
bulkCacheDscContent(),
summarizeCacheOperation(),
);
return cached$;
});
};
// Separating this out so it's easier to mock for testing purposes
export const collectionContentAjaxLoad = (cfg = {}) => (inputs$) => {
const { onceEvery = 10 * 1000 } = cfg;
return inputs$.pipe(
map(buildDscContentUrl),
throttleDistinct({ timeout: onceEvery }),
map(prependPath),
requestWithUpdateTime({ dateStore }),
pluck("response")
);
// Add in the url we found this content at because that is going to be like
// a parent_id in the database
const response$ = ajaxResponse$.pipe(
withLatestFrom(url$),
map(([response, url]) => response.map((row) => ({ ...row, parent_url: url })))
);
const cached$ = response$.pipe(
bulkCacheDscContent(),
summarizeCacheOperation()
);
return cached$; // enqueue(load$, "load contents");
};
// Collection + params -> request url w/o update_time
// ignore params for now, we don't filter collection contents yet.
export const buildDscContentUrl = (inputs) => {
const buildDscContentUrl = (inputs) => {
// eslint-disable-next-line no-unused-vars
const [url, filters, cursor] = inputs;
const limit = SearchParams.pageSize;
const skipClause = cursor ? `offset=${cursor}` : "";
const [url, filters, { offset = 0, limit = SearchParams.pageSize }] = inputs;
const skipClause = offset ? `offset=${offset}` : "";
const limitClause = limit ? `limit=${limit}` : "";
const qs = [skipClause, limitClause].filter((o) => o.length).join("&");
return `${url}?${qs}`;
@@ -1,10 +1,7 @@
import { pipe } from "rxjs";
import { map, withLatestFrom, pluck, shareReplay } from "rxjs/operators";
import { throttleDistinct } from "utils/observable/throttleDistinct";
import { tag } from "rxjs-spy/operators/tag";
import { hydrate } from "./operators/hydrate";
import { requestWithUpdateTime, requestWithUpdateTimeNoInitial } from "./operators/requestWithUpdateTime";
import { zip } from "rxjs";
import { map, pluck, share } from "rxjs/operators";
import { hydrate } from "utils/observable";
import { requestWithUpdateTime } from "./operators/requestWithUpdateTime";
import { prependPath } from "./workerConfig";
import { bulkCacheContent } from "./db";
import { SearchParams } from "../model/SearchParams";
@@ -19,79 +16,63 @@ export const clearHistoryDateStore = async () => {
};
/**
* Turn [historyId, params, hid] into content requests. Cache results and send
* Turn [historyId, params, pagination] into content requests. Cache results and send
* back statistics about what was returned for this query.
*/
// prettier-ignore
export const loadHistoryContents = (cfg = {}) => (rawInputs$) => {
const {
onceEvery = 30 * 1000,
windowSize = SearchParams.pageSize,
noInitial = false
const {
noInitial = false,
windowSize = SearchParams.pageSize
} = cfg;
// if noInitial, only returns updates
const dateAppender = noInitial ? requestWithUpdateTimeNoInitial : requestWithUpdateTime
const inputs$ = rawInputs$.pipe(
shareReplay(1)
hydrate([undefined, SearchParams]),
);
const ajaxResponse$ = inputs$.pipe(
hydrate([undefined, SearchParams]),
map(([id, params, hid]) => {
const baseUrl = `/api/histories/${id}/contents/near/${hid}/${windowSize}`;
return `${baseUrl}?${params.historyContentQueryString}`;
}),
throttleDistinct({ timeout: onceEvery }),
map(prependPath),
dateAppender({ dateStore }),
shareReplay(1),
requestWithUpdateTime({ dateStore, noInitial }),
share(),
);
const cacheSummary$ = ajaxResponse$.pipe(
pluck("response"),
bulkCacheContent(),
summarizeCacheOperation(),
tag("loadHistoryContents-cacheSummary"),
);
return cacheSummary$.pipe(
withLatestFrom(ajaxResponse$, inputs$),
map(([summary, ajaxResponse, inputs]) => {
// actual list
return zip(ajaxResponse$, cacheSummary$).pipe(
map(([ajaxResponse, summary]) => {
const { xhr, response = [] } = ajaxResponse;
const { max: maxContentHid, min: minContentHid } = getPropRange(response, "hid");
const headerInt = field => parseInt(xhr.getResponseHeader(field));
// header counts
const matchesUp = +xhr.getResponseHeader("matches_up");
const matchesDown = +xhr.getResponseHeader("matches_down");
const totalMatchesUp = +xhr.getResponseHeader("total_matches_up");
const totalMatchesDown = +xhr.getResponseHeader("total_matches_down");
const minHid = +xhr.getResponseHeader("min_hid");
const maxHid = +xhr.getResponseHeader("max_hid");
const matchesUp = headerInt("matches_up");
const matchesDown = headerInt("matches_down");
const totalMatchesUp = headerInt("total_matches_up");
const totalMatchesDown = headerInt("total_matches_down");
const minHid = headerInt("min_hid");
const maxHid = headerInt("max_hid");
return {
// original inputs
inputs,
// cache summary
summary,
// derived from the returned content list
minContentHid,
maxContentHid,
// ajax result stats
matches: matchesUp + matchesDown,
totalMatches: totalMatchesUp + totalMatchesDown,
minHid,
maxHid,
minContentHid, // minimum hid in the returned result
maxContentHid, // maximum hid in the returned result
matchesUp,
matchesDown,
matches: matchesUp + matchesDown,
totalMatchesUp,
totalMatchesDown,
totalMatches: totalMatchesUp + totalMatchesDown
};
})
);
@@ -103,15 +84,15 @@ export const loadHistoryContents = (cfg = {}) => (rawInputs$) => {
* summarizes what pouchdb did during the bulk cache and sends back some stats.
*/
// prettier-ignore
export const summarizeCacheOperation = () => pipe(
map((list) => {
export const summarizeCacheOperation = () => {
return map((list) => {
const cached = list.filter((result) => result.updated || result.ok);
return {
updatedItems: cached.length,
totalReceived: list.length,
};
})
);
}
/**
* Gets min and max values from an array of objects
@@ -1,10 +1,33 @@
import { merge, partition, concat } from "rxjs";
import { map, share, shareReplay, pluck, take, reduce, distinctUntilChanged } from "rxjs/operators";
import { content$, buildContentId } from "./db/observables";
import { monitorQuery, ACTIONS } from "./db/monitorQuery";
import { hydrate } from "./operators/hydrate";
import { merge } from "rxjs";
import { map, distinctUntilChanged } from "rxjs/operators";
import { content$, dscContent$, buildContentId, buildCollectionId } from "./db/observables";
import { monitorQuery } from "./db/monitorQuery";
import { SearchParams } from "../model/SearchParams";
import { deepEqual } from "deep-equal";
import { hydrate, shareButDie, show } from "utils/observable";
import { SEEK } from "./enums";
// history contents monitor
export const monitorHistoryContent = (cfg = {}) => {
const { aggregationKeyField = "hid", ...moreCfg } = cfg;
return twoWayMonitor({
...moreCfg,
db$: content$,
buildRequest: buildContentPouchRequest,
aggregationKeyField,
});
};
// collection contents monitor
export const monitorCollectionContent = (cfg = {}) => {
const { aggregationKeyField = "element_index", ...moreCfg } = cfg;
return twoWayMonitor({
...moreCfg,
db$: dscContent$,
buildRequest: buildCollectionPouchRequest,
aggregationKeyField,
});
};
/**
* Search cache upward and downward from the scroll HID, filtering out results
@@ -13,68 +36,58 @@ import { deepEqual } from "deep-equal";
* contents if the user is rapidly dragging the scrollbar to different regions
*/
// prettier-ignore
export const monitorHistoryContent = (cfg = {}) => (src$) => {
// console.warn("monitorHistoryContent operator", cfg);
export const twoWayMonitor = (cfg = {}) => (src$) => {
const {
db$ = content$,
pages = 1,
db$,
aggregationKeyField,
buildRequest = buildContentPouchRequest,
pageSize = SearchParams.pageSize,
bench = pages * pageSize,
limit = pages * pageSize,
inputDebounce = 0,
inputDebounce = 100,
debug = false,
} = cfg;
const input$ = src$.pipe(
hydrate([undefined, SearchParams]),
shareReplay(1),
show(debug, ([id, filters, hid]) => console.log("twoWayMonitor inputs", id, hid)),
hydrate([undefined, SearchParams]),
shareButDie(1)
);
// one page up
const up$ = input$.pipe(
map(buildContentPouchRequest({
seek: "asc",
limit: bench
})),
map(buildRequest({ seek: SEEK.ASC, pageSize })),
distinctUntilChanged(deepEqual),
monitorQuery({
db$,
inputDebounce,
}),
show(debug, (req) => console.log("twoWayMonitor request (up)", req.selector)),
monitorQuery({ db$, inputDebounce, debug, label: "up" }),
show(debug, ({ doc, match }) => console.log("twoWayMonitor response (up)", doc[aggregationKeyField], match)),
);
// current page + next page = 2 pages down
const down$ = input$.pipe(
map(buildContentPouchRequest({
seek: "desc",
limit
})),
map(buildRequest({ seek: SEEK.DESC, pageSize })),
distinctUntilChanged(deepEqual),
monitorQuery({
db$,
inputDebounce,
show(debug, (req) => console.log("twoWayMonitor request (down)", req.selector)),
monitorQuery({ db$, inputDebounce, debug, label: "down" }),
show(debug, ({ doc, match }) => console.log("twoWayMonitor response (down)", doc[aggregationKeyField], match)),
);
const change$ = merge(up$, down$).pipe(
map((response) => {
// don't bother sending back non-matching results
// we're just going to delete them from the skiplist
// using the aggregation key, so no need to serialize them
const { match, doc, ...more} = response;
const key = doc[aggregationKeyField];
return match ? { key, match, doc, ...more } : { key, match, ...more };
}),
show(debug, update => {
const { key, match } = update;
console.log("monitorHistoryContent change", key, match);
}),
);
// merge both up and down initial events into a single initial event
// (reduces bouncing in the output)
const all$ = merge(up$, down$).pipe(share());
const splitter = evt => evt.action == ACTIONS.INITIAL;
const [ allInital$, update$ ] = partition(all$, splitter);
const initial$ = allInital$.pipe(
pluck('initialMatches'),
take(2),
reduce((allDocs, docs) => allDocs.concat(docs), []),
map(docs => ({ action: ACTIONS.INITIAL, initialMatches: docs })),
);
return concat(initial$, update$);
return change$;
};
// seek types
export const SEEK = { ASC: "asc", DESC: "desc" };
/**
* Generate a PouchDB selector for a specified history, filters, and a rough
* guess of where to start looking.
@@ -83,19 +96,25 @@ export const SEEK = { ASC: "asc", DESC: "desc" };
* and we need to be able to select without loading everything
*/
export const buildContentPouchRequest = (cfg = {}) => (inputs) => {
const { limit = SearchParams.pageSize, seek = SEEK.DESC } = cfg;
const { seek, pageSize = SearchParams.pageSize } = cfg;
const [history_id, params, hid] = inputs;
// look up or down from target hid
const targetId = buildContentId({ history_id, hid });
// SEEK.ASC means the top seek, HID > target
const comparator = seek == SEEK.ASC ? "$gt" : "$lte";
// one page above the target, then 2 after to get the current page and some buffer
const pages = seek == SEEK.ASC ? 1 : 2;
const limit = pages * pageSize;
// index, will build if not existent
const filterFields = Array.from(params.criteria.keys()).map((f) => params.getPouchFieldName(f));
const fields = ["_id", "history_id", ...filterFields];
const ddoc = "idx-" + fields.sort().join("-");
return {
const request = {
selector: {
$and: [
// doc id + direction (up/down)
@@ -111,4 +130,43 @@ export const buildContentPouchRequest = (cfg = {}) => (inputs) => {
ddoc,
},
};
return request;
};
// filters currently unused, but they could be if we add some filter UI
export const buildCollectionPouchRequest = (cfg = {}) => (inputs) => {
const { seek, pageSize = SearchParams.pageSize } = cfg;
// eslint-disable-next-line no-unused-vars
const [parent_url, filters, element_index] = inputs;
const targetId = buildCollectionId({ parent_url, element_index });
// SEEK.ASC means above the target means element_index < target
const comparator = seek == SEEK.ASC ? "$lt" : "$gte";
// 3 pages total, 1 before, the current one, 1 after;
const pages = seek == SEEK.ASC ? 1 : 2;
const limit = pages * pageSize;
// when searching upward we invert the sort because we want the
// results closest to the target
const idSort = seek == SEEK.ASC ? "desc" : "asc";
const request = {
selector: {
_id: { [comparator]: targetId },
parent_url: { $eq: parent_url },
},
sort: [{ _id: idSort }],
limit,
index: {
fields: ["_id"],
sort: [{ _id: "asc" }],
name: "collection contents by parent_url and element_index ascending",
ddoc: "idx-dsc-contents-by-url-and-element-asc",
},
};
return request;
};
@@ -1,24 +1,28 @@
import { timer, of } from "rxjs";
import { take, pluck, takeUntil } from "rxjs/operators";
import { takeUntil } from "rxjs/operators";
import { firstValueFrom } from "utils/observable/firstValueFrom";
import { wipeDatabase } from "./db/wipeDatabase";
import { wait } from "jest/helpers";
import { ObserverSpy } from "@hirez_io/observer-spy";
import { SearchParams } from "../model/SearchParams";
import { content$, buildContentId } from "../caching/db/observables";
import { bulkCacheContent, getCachedContent, cacheContent } from "../caching/db/promises";
import { content$ } from "../caching/db/observables";
import { bulkCacheContent, cacheContent } from "../caching/db/promises";
import { find } from "../caching/db/find";
import { buildContentPouchRequest, monitorHistoryContent } from "./monitorHistoryContent";
import { ACTIONS } from "../caching/db/monitorQuery";
// test data
import historyContent from "../test/json/historyContent.json";
// import collectionContent from "../../test/json/collectionContent.json";
jest.mock("app");
jest.mock("../caching");
beforeEach(wipeDatabase);
afterEach(wipeDatabase);
const monitorSpinUp = 100;
const monitorSafetyTimeout = 1000;
const selectorHasField = (selector, field) => selector.$and.some((row) => row[field] !== undefined);
describe("buildContentPouchRequest", () => {
@@ -39,7 +43,16 @@ describe("buildContentPouchRequest", () => {
});
describe("monitorHistoryContent", () => {
beforeEach(async () => await bulkCacheContent(historyContent));
let cachedContentMap;
// cache sample content, store in a map for later, keyed by HID
beforeEach(async () => {
const cachedContent = await bulkCacheContent(historyContent, true);
const cachedContentEntries = cachedContent.map((o) => [o.hid, o]);
cachedContentMap = new Map(cachedContentEntries);
});
afterEach(() => (cachedContentMap = null));
test("sanity check all the data is in there", async () => {
const { history_id } = historyContent[0];
@@ -54,28 +67,25 @@ describe("monitorHistoryContent", () => {
const monitor$ = of([history_id, new SearchParams(), hid]).pipe(
monitorHistoryContent({
inputDebounce: 50,
pageSize: 100000000, // get everything without pagination for now
pageSize: 10, // get everything without pagination for now
}),
pluck("initialMatches"),
take(1),
takeUntil(timer(2000)) // safety
takeUntil(timer(monitorSafetyTimeout)) // safety
);
const spy = new ObserverSpy();
monitor$.subscribe(spy);
await spy.onComplete();
expect(spy.getValuesLength()).toBeGreaterThan(0);
// monitorHistoryContent has 2 seeks, one up, one down, so two emits
// with initialMatches, some of the test rows have been doctored so they
// don't match the initial filters
const evts = spy.getValues();
const matches = evts.reduce((acc, matches) => acc + matches.length, 0);
// show only visible, undeleted rows, there are a couple that don't match
const expectedRows = historyContent.filter((row) => row.deleted == false && row.visible == true);
expect(expectedRows.length).not.toEqual(historyContent.length);
expect(matches).toEqual(expectedRows.length);
spy.getValues().forEach((evt) => {
const { key, match, doc } = evt;
expect(match).toBe(true);
expect(doc.hid).toEqual(key);
expect(cachedContentMap.has(key)).toBe(true);
// we had default params
expect(doc.isDeleted).toBe(false);
expect(doc.visible).toBe(true);
});
});
test("should see subsequent updates", async () => {
@@ -85,37 +95,29 @@ describe("monitorHistoryContent", () => {
const monitor$ = of(inputs).pipe(
monitorHistoryContent({ inputDebounce: 50 }),
take(2),
takeUntil(timer(2000)) // safety, ends stream if test fails
takeUntil(timer(monitorSafetyTimeout)) // safety
);
// monitor observable
const spy = new ObserverSpy();
monitor$.subscribe(spy);
// wait for first emission before updating
await wait(500);
// wait for first emission before updating, this lets all
// the initial matches emit
await wait(monitorSpinUp);
// update doc
const lookup = await getCachedContent(buildContentId(firstDoc));
lookup.foobar = 123;
const cached = await cacheContent(lookup);
expect(cached.updated).toBeTruthy();
// update one doc
const firstHid = historyContent[0].hid;
const updateMe = cachedContentMap.get(firstHid);
const fakeVal = 123;
updateMe.foobar = fakeVal;
const updatedContent = await cacheContent(updateMe, true);
expect(updatedContent.foobar).toEqual(fakeVal);
// wait until monitor completes
await spy.onComplete();
expect(spy.getValuesLength()).toEqual(2);
expect(spy.receivedError()).toBe(false);
expect(spy.receivedNext()).toBe(true);
expect(spy.receivedComplete()).toBe(true);
// first emission should have been the initial load
const firstUpdate = spy.getFirstValue();
expect(firstUpdate.action).toEqual(ACTIONS.INITIAL);
const lastUpdate = spy.getLastValue();
expect(lastUpdate.action).toEqual(ACTIONS.UPDATE);
expect(lastUpdate.doc._id).toEqual(lookup._id);
expect(lastUpdate.doc.foobar).toEqual(123);
const lastEmit = spy.getLastValue();
expect(lastEmit.doc.foobar).toEqual(fakeVal);
});
});
@@ -23,10 +23,17 @@ export const requestWithUpdateTime = (config = {}) => {
bufferSeconds = 0,
dateFieldName = "update_time",
requestTime = moment.utc(),
// indicates we don't want initial results
noInitial = false
} = config;
// mark and flag this update-time, append to next request with same base
return pipe(
tap((url) => {
if (noInitial && !dateStore.has(url)) {
dateStore.set(url, moment.utc());
}
}),
mergeMap((baseUrl) => of(baseUrl).pipe(
appendUpdateTime({ dateStore, bufferSeconds, dateFieldName }),
mergeMap(ajax),
@@ -35,22 +42,6 @@ export const requestWithUpdateTime = (config = {}) => {
);
};
// prettier-ignore
// Like request withUpdateTime but omits initial results
export const requestWithUpdateTimeNoInitial = (config = {}) => {
const {
dateStore = requestDateStore,
} = config;
return pipe(
tap((url) => {
if (!dateStore.has(url)) {
dateStore.set(url, moment.utc());
}
}),
requestWithUpdateTime(config)
);
}
/**
* Takes a base URL appends an update_time-gt restriction based on the lst
* time this URL was requestd as indicated by the dateStore.
@@ -60,7 +51,7 @@ export const requestWithUpdateTimeNoInitial = (config = {}) => {
const appendUpdateTime = (cfg = {}) => {
const {
dateStore = requestDateStore,
dateFieldName = "update_time"
dateFieldName = "update_time",
} = cfg;
return pipe(
@@ -4,7 +4,7 @@
import moment from "moment";
import { pipe } from "rxjs";
import { filter } from "rxjs/operators";
import { createDateStore } from "../model/DateStore";
import { createDateStore } from "../../model/DateStore";
export const throttleDistinctDateStore = createDateStore("throttleDistinct default");
@@ -0,0 +1,16 @@
export class ScrollPos {
constructor(props = {}) {
const { cursor = 0.0, key = null } = props;
this.cursor = cursor;
this.key = key;
}
}
ScrollPos.create = function (props) {
const o = new ScrollPos(props);
return Object.freeze(o);
};
ScrollPos.equals = function (a, b) {
return a.cursor == b.cursor && a.key == b.key;
};
@@ -1,4 +1,3 @@
import config from "config";
import deepEqual from "deep-equal";
import { isString } from "underscore";
@@ -126,43 +125,68 @@ export class SearchParams {
return filters;
}
// render a query string for use in querying content from the contnts/near endoint
// Creates an object of query criteria for use with the api
get historyContentQueryFields() {
return Array.from(this.criteria)
.map(([userField, val]) => {
let serverField = this.getServerFieldName(userField);
let serverVal = val;
switch (serverField) {
// some client-side filters do not correspond to filters on the server
// they can be used to filter local results but will not affect the polling
// TODO: consider adding them as available filter options on the api?
case null:
return [];
// This is advertised to work, but is broken on the current api
case "annotation":
case "description":
return [];
// non-standard REST bools
// deleted serverField was reserved by pouchDB, needed to rename it to "isDeleted"
case "deleted":
case "visible":
serverVal = dumbBool(val);
break;
// no text searching in some fields
case "hid":
case "state":
case "history_content_type":
case "type":
break;
// assume text-contains search
default:
serverField = serverField + "-contains";
serverVal = encodeURIComponent(val);
break;
}
return [serverField, serverVal];
})
.filter((pair) => pair.length == 2)
.reduce((fields, [k, v]) => {
fields[k] = v;
return fields;
}, {});
}
// legacy q/qv syntax for content api
// TODO: Delete when we no longer use q/qv
get legacyContentQueryString() {
return Object.entries(this.historyContentQueryFields)
.map(([f, v]) => `q=${f}&qv=${v}`)
.join("&");
}
// Standard query string (field=val&field=val...)
get historyContentQueryString() {
const parts = Array.from(this.criteria).map(([userField, val]) => {
const serverField = this.getServerFieldName(userField);
switch (serverField) {
// some client-side filters do not correspond to filters on the server
// they can be used to filter local results but will not affect the polling
// TODO: consider adding them as available filter options on the api?
case null:
return "";
// This is advertised to work, but is broken on the current api
case "annotation":
case "description":
return "";
// non-standard REST bools
// deleted serverField was reserved by pouchDB, needed to rename it to "isDeleted"
case "deleted":
case "visible":
return `${serverField}=${dumbBool(val)}`;
// no text searching in some fields
case "hid":
case "state":
case "history_content_type":
case "type":
return `${serverField}=${val}`;
// assume text-contains search
default:
return `${serverField}-contains=${encodeURIComponent(val)}`;
}
});
return parts.filter((o) => o.length).join("&");
return Object.entries(this.historyContentQueryFields)
.map(([f, v]) => `${f}=${v}`)
.join("&");
}
// maps friendly user field name to internal data field if necessary
@@ -177,12 +201,10 @@ export class SearchParams {
}
// output current state to log
report(label = "params") {
report(label = "params", collapsed = true) {
const { showDeleted, showHidden, filterText } = this;
const dString = showDeleted ? "showDeleted" : "";
const hString = showHidden ? "showHidden" : "";
console.groupCollapsed(label, `(${dString} ${hString}`);
const consoleOpen = collapsed ? console.groupCollapsed : console.group;
consoleOpen.call(console, label);
console.log("showDeleted", showDeleted);
console.log("showHidden", showHidden);
console.log("filterText", filterText);
@@ -192,8 +214,11 @@ export class SearchParams {
// Statics
SearchParams.pageSize = config?.caching?.pageSize || 50;
SearchParams.pageSize = 30;
SearchParams.equals = function (a, b) {
return deepEqual(a.export(), b.export());
if (a !== undefined && b !== undefined) {
return deepEqual(a.export(), b.export());
}
return false;
};
@@ -5,6 +5,7 @@ export { JobStateSummary } from "./JobStateSummary";
export { DateStore } from "./DateStore";
export { History } from "./History";
export { SearchParams } from "./SearchParams";
export { ScrollPos } from "./ScrollPos";
export { CurveFit } from "./CurveFit";
export { STATES } from "./states";
@@ -1,21 +1,6 @@
/**
* Same basic format as historyContentProvider, but it should be simpler than
* the history because:
*
* 1. Collections are immutable after creation so they don't change over time,
* meaning there is no polling and no need to do "random access" as we do for
* histories since we can just limit/offset to reach everything since the
* total matches count will always be the same. A "total matches" exists in
* the basic content record in the form of "element_count", and we already
* have that value in the DSC which is passed in.
*
* 2. There is no filtering for collections on the server so all we can do is
* page through the results.
*/
import { DatasetCollection } from "../../model";
import { ContentProvider } from "../ContentProvider";
import { processCollectionStreams } from "./processCollectionStreams";
import { ContentProvider, processContentStreams } from "../ContentProvider";
import { collectionPayload } from "./collectionPayload";
export default {
mixins: [ContentProvider],
@@ -39,11 +24,11 @@ export default {
methods: {
initStreams() {
const { debouncePeriod, pageSize, params$, scrollPos$ } = this;
const dsc$ = this.watch$("dsc", true);
const sources = { params$, dsc$, scrollPos$ };
const settings = { debouncePeriod, pageSize };
return processCollectionStreams(sources, settings);
const { debouncePeriod, pageSize, params$, scrollPos$, debug } = this;
const parent$ = this.watch$("dsc", true);
const sources = { params$, parent$, scrollPos$ };
const settings = { debouncePeriod, pageSize, debug };
return processContentStreams(collectionPayload, sources, settings);
},
},
};
@@ -1,172 +1,154 @@
import { NEVER } from "rxjs";
import { cacheContent, cacheCollectionContent, bulkCacheDscContent, wipeDatabase } from "../../caching";
import { map } from "rxjs/operators";
import { watchUntil, getLocalVue } from "jest/helpers";
import { shallowMount } from "@vue/test-utils";
import { cacheContent, cacheCollectionContent, wipeDatabase, loadDscContent } from "../../caching";
import { bulkCacheDscContent } from "../../caching/db/observables";
import { summarizeCacheOperation } from "../../caching/loadHistoryContents";
import { SearchParams } from "../../model/SearchParams";
import CollectionContentProvider from "./CollectionContentProvider";
import { watchForChange, mountRenderless, getLocalVue } from "jest/helpers";
// sample data
import rawRootCollection from "../../test/json/DatasetCollection.json";
import rawCollection from "../../test/json/collectionContent.json";
import rawNestedCollection from "../../test/json/collectionContent.nested.json";
//#region Mocking
import { sameVueObj, payloadChange } from "../../test/providerTestHelpers";
import { testCollection, testCollectionContent } from "../../test/testCollection";
import { defaultPayload } from "../ContentProvider";
// import { reportPayload } from "../../test/providerTestHelpers";
// mocking
jest.mock("app");
jest.mock("../../caching");
import { loadCollectionContents } from "./loadCollectionContents";
jest.mock("./loadCollectionContents");
// Don't actually load anything, we're testing the cache response
loadCollectionContents.mockImplementation((config) => (src$) => {
return NEVER;
// fake server call
// prettier-ignore
loadDscContent.mockImplementation((cfg) => (inputs$) => {
return inputs$.pipe(
map((inputs) => {
const [url, , pagination] = inputs;
const { offset, limit } = pagination;
// console.log("mock loader", url, pagination);
const result = testCollectionContent
.filter((row) => url == row.parent_url)
.slice(offset, offset + limit);
return result;
}),
bulkCacheDscContent(),
summarizeCacheOperation()
);
});
//#endregion
let dsc;
const localVue = getLocalVue();
const mountProvider = async (Component, propsData) => {
const localVue = getLocalVue();
const wrapper = mountRenderless(Component, { localVue, propsData });
await wrapper.vm.$nextTick();
return wrapper;
};
beforeEach(async () => {
dsc = await cacheContent(testCollection, true);
});
afterEach(async () => {
dsc = null;
await wipeDatabase();
});
describe("CollectionContentProvider", () => {
beforeEach(wipeDatabase);
afterEach(wipeDatabase);
const pageSize = 5;
const debouncePeriod = 250;
let wrapper;
afterEach(() => {
afterEach(async () => {
if (wrapper) wrapper.destroy();
wrapper = undefined;
});
describe("initialization state", () => {
let parent;
// No loading
describe("blank initialization state", () => {
// current payload
beforeEach(async () => {
parent = await cacheContent(rawRootCollection, true);
wrapper = await mountProvider(CollectionContentProvider, {
parent,
disableLoad: true,
wrapper = await shallowMount(CollectionContentProvider, {
localVue,
propsData: {
parent: dsc,
pageSize,
debug: false,
debouncePeriod,
},
scopedSlots: {
default() {},
},
});
await wrapper.vm.$nextTick();
});
test("should load with default values", async () => {
await watchForChange({ vm: wrapper.vm, propName: "payload" });
const { contents, startKey, topRows, bottomRows, totalMatches } = wrapper.vm.payload;
// no content, so no startKey and empty contnets
expect(contents).toEqual([]);
expect(startKey).toEqual(undefined);
// result derived entirely from parent
expect(topRows).toEqual(0);
expect(bottomRows).toEqual(parent.element_count);
expect(totalMatches).toEqual(parent.element_count);
// check passthrough vals
expect(wrapper.vm.params).toEqual(new SearchParams());
expect(wrapper.vm.pageSize).toEqual(SearchParams.pageSize);
// check exposed methods
expect(wrapper.vm.updateParams).toBeInstanceOf(Function);
test("should expose update methods", () => {
// allows child scroller component to update the position in the history
// via an event handler
expect(wrapper.vm.setScrollPos).toBeInstanceOf(Function);
});
test("should show an empty payload", () => {
expect(SearchParams.equals(new SearchParams(), wrapper.vm.params)).toBe(true);
expect(sameVueObj(wrapper.vm.payload, defaultPayload));
});
});
describe("should emit payload to reflect cached content", () => {
let parent;
let children;
let grandChildren;
// dummy up children to have matching ids
const loadChildren = async (childProps, parent_url, returnDocs) => {
const processed = childProps.map((props) => ({ parent_url, ...props }));
return await bulkCacheDscContent(processed, returnDocs);
};
const loadCollectionTree = async () => {
// root dsc
parent = await cacheContent(rawRootCollection, true);
// first layer of collection contents
children = await loadChildren(rawCollection, parent.contents_url, true);
// 2nd layer of nested collection contents
grandChildren = [];
for (const child of children) {
const gc = await loadChildren(rawNestedCollection, child.contents_url, true);
grandChildren.push(...gc);
}
};
// No loading
describe("waiting for simulated server loads", () => {
beforeEach(async () => {
await loadCollectionTree();
parent = await cacheContent(rawRootCollection, true);
wrapper = await mountProvider(CollectionContentProvider, {
parent,
disableLoad: true,
wrapper = await shallowMount(CollectionContentProvider, {
localVue,
propsData: {
parent: dsc,
pageSize,
debug: false,
debouncePeriod,
},
scopedSlots: {
default: function (props) {
// reportPayload(props.payload, { indexKey: "element_index" });
// payload = props.payload;
},
},
});
});
test("should load with pre-inserted cached children values", async () => {
await watchForChange({ vm: wrapper.vm, propName: "payload" });
const { contents, startKey, topRows, bottomRows, totalMatches } = wrapper.vm.payload;
const { params, pageSize } = wrapper.vm;
const ids = contents.map((o) => o.id);
const childIds = children.map((o) => o.id);
expect(ids).toEqual(childIds);
expect(startKey).toBe(0);
expect(topRows).toBe(0);
expect(bottomRows).toBe(0); // length of all children < pagesize
expect(totalMatches).toBe(children.length);
expect(params).toEqual(new SearchParams());
expect(pageSize).toEqual(SearchParams.pageSize);
});
// If the cache adds some content, should still be at the top, but the
// bottomRows should change to reflect the new rows that are now
// rendered in the contents
test("should emit when a new item is inserted", async () => {
await watchForChange({ vm: wrapper.vm, propName: "payload" });
expect(wrapper.vm.payload.contents.length).toEqual(children.length);
// clone and update a row
const lastChild = children[children.length - 1];
// eslint-disable-next-line no-unused-vars
const { _id, _rev, ...existingProps } = lastChild;
const newChild = {
...existingProps,
element_index: existingProps.element_index + 1,
};
const spinUp = await watchUntil(wrapper.vm, function () {
const allDone = wrapper.vm.payload.contents.length > 0;
// console.log("done?", allDone);
return allDone;
});
const insertResult = await cacheCollectionContent(newChild);
expect(insertResult.updated).toBe(true);
await watchForChange({ vm: wrapper.vm, propName: "payload" });
const { contents } = wrapper.vm.payload;
expect(contents.length).toBe(children.length + 1);
// console.log(spinUp);
});
test("should emit when an existing item is updated", async () => {
await watchForChange({ vm: wrapper.vm, propName: "payload" });
test("initial load, no scrolling", async () => {
const payload = wrapper.vm.payload;
expect(payload).toBeDefined();
// reportPayload(payload, { indexKey: "element_index" });
// reportPayload(initialPayload, { indexKey: "element_index" });
const { contents, topRows, bottomRows, startKey, totalMatches } = payload;
expect(topRows).toEqual(0);
expect(contents.length).toBeGreaterThanOrEqual(2 * wrapper.vm.pageSize);
expect(totalMatches).toEqual(testCollectionContent.length);
expect(bottomRows).toBeGreaterThan(0);
expect(bottomRows).toEqual(testCollectionContent.length - contents.length);
const lastChild = children[children.length - 1];
lastChild.foo = 123;
const testStartIndex = testCollectionContent[0].element_index;
expect(startKey).toEqual(testStartIndex);
});
const updatedResult = await cacheCollectionContent(lastChild);
expect(updatedResult.updated).toBe(true);
test("should update to reflect independent cache changes", async () => {
let hasFoobar;
const payload = wrapper.vm.payload;
await watchForChange({ vm: wrapper.vm, propName: "payload" });
// check initial emit, for foobars
expect(payload).toBeDefined();
hasFoobar = payload.contents.some((o) => o.foobar == 123);
expect(hasFoobar).toBeFalsy();
const { contents } = wrapper.vm.payload;
expect(contents.length).toBe(children.length);
// add a foobar
const testChild = payload.contents[0];
testChild.foobar = 123;
const updateResult = await cacheCollectionContent(testChild, true);
expect(updateResult.foobar).toEqual(123);
// check subsequent payload for a foobar
const updatedPayload = await payloadChange({ vm: wrapper.vm });
// reportPayload(updatedPayload, { label: "updated", indexKey: "element_index" });
hasFoobar = updatedPayload.contents.some((o) => o.foobar == 123);
expect(hasFoobar).toBeTruthy();
});
});
});
@@ -0,0 +1,69 @@
import { of, Observable, BehaviorSubject } from "rxjs";
import { tap, map, pluck, switchMap, share, debounceTime } from "rxjs/operators";
import { chunkProp, show } from "utils/observable";
import { SearchParams } from "../../model/SearchParams";
import { buildPaginationWindow } from "../ContentProvider";
import { loadDscContent } from "../../caching";
import { watchCollection } from "./watchCollection";
// import { reportPayload } from "../../test/providerTestHelpers";
// prettier-ignore
export const collectionPayload = (cfg = {}) => pos$ => {
const {
parent: dsc,
filters = new SearchParams(),
totalMatches = dsc.totalElements,
pageSize = SearchParams.pageSize,
debouncePeriod = 250,
debug = false,
loading$ = new BehaviorSubject(),
} = cfg;
const { contents_url } = dsc;
const cursor$ = pos$.pipe(
pluck('cursor'),
show(debug, cursor => console.log("collectionPayload input cursor", cursor)),
share()
);
// Server loads
const pagination$ = cursor$.pipe(
map((cursor) => buildPaginationWindow(cursor, totalMatches, pageSize)),
chunkProp('offset', pageSize / 2),
show(debug, pagination => console.log("collectionPayload pagination", pagination)),
);
const serverLoad$ = pagination$.pipe(
tap(() => loading$.next(true)),
switchMap(pagination => of([contents_url, filters, pagination]).pipe(
loadDscContent(),
)),
show(debug, val => console.log("collectionPayload loadDscContent response", val)),
);
// Cache Watcher
// convert cursor to key: element_index, this is a lot easier than on
// history because we don't filter (yet) and the index is numeric starting
// at the top, so all we have to do is scale it
const payload$ = cursor$.pipe(
map((cursor) => cursor * totalMatches),
watchCollection({ dsc, filters, ...cfg}),
debounceTime(debouncePeriod),
map((result) => {
const { contents } = result;
const topRows = contents.length ? contents[0].element_index : 0;
const bottomRows = Math.max(0, totalMatches - contents.length - topRows);
return { ...result, topRows, bottomRows, totalMatches };
}),
);
// create observable that looks at the cache but piggybacks the loader subscription
// piggyback the subscriptions
return new Observable((obs) => {
const watchSub = payload$.subscribe(obs);
watchSub.add(serverLoad$.subscribe());
return watchSub;
})
};
@@ -1,3 +1,18 @@
/**
* Same basic format as historyContentProvider, but it should be simpler than
* the history because:
*
* 1. Collections are immutable after creation so they don't change over time,
* meaning there is no polling and no need to do "random access" as we do for
* histories since we can just limit/offset to reach everything since the
* total matches count will always be the same. A "total matches" exists in
* the basic content record in the form of "element_count", and we already
* have that value in the DSC which is passed in.
*
* 2. There is no filtering for collections on the server so all we can do is
* page through the results
*/
import CollectionContentProvider from "./CollectionContentProvider";
export default CollectionContentProvider;
@@ -1,17 +0,0 @@
import { of } from "rxjs";
import { switchMap } from "rxjs/operators";
import { tag } from "rxjs-spy/operators/tag";
import { loadDscContent } from "../../caching";
export const loadCollectionContents = (cfg = {}) => {
const { windowSize = 100 } = cfg;
// prettier-ignore
return switchMap(([dsc, params, idx]) => {
const { contents_url } = dsc;
return of([contents_url, params, idx]).pipe(
tag("ajaxLoadInputs"),
loadDscContent({ windowSize })
);
});
};
@@ -1,102 +0,0 @@
import { merge, combineLatest } from "rxjs";
import { map, pluck, distinctUntilChanged, share, debounceTime } from "rxjs/operators";
import { tag } from "rxjs-spy/operators/tag";
import { whenAny } from "utils/observable/whenAny";
import { activity } from "utils/observable/activity";
import { shareButDie } from "utils/observable/shareButDie";
import { chunk } from "../../caching/operators/chunk";
import { DatasetCollection, SearchParams } from "../../model";
import { watchCollectionCache } from "./watchCollectionCache";
import { loadCollectionContents } from "./loadCollectionContents";
import { loadInputsSame } from "../HistoryContentProvider/processHistoryStreams";
// prettier-ignore
export function processCollectionStreams(sources, settings) {
const { debouncePeriod, pageSize } = settings;
const { dsc$: rawDsc$, params$: rawParams$, scrollPos$ } = sources;
//#region Clean up, filter, debounce raw inputs
const dsc$ = rawDsc$.pipe(distinctUntilChanged(DatasetCollection.equals));
const params$ = rawParams$.pipe(distinctUntilChanged(SearchParams.equals));
const inputs$ = whenAny(dsc$, params$).pipe(tag('inputs'), share());
const scrolling$ = scrollPos$.pipe(activity());
const totalMatches$ = dsc$.pipe(pluck("totalElements"));
//#endregion
//#region Estimate element index from cursor + dsc
const cursor$ = whenAny(scrollPos$, totalMatches$).pipe(
map(([pos, totalMatches]) => getIndexFromPos(pos, totalMatches)),
distinctUntilChanged(),
tag('cursor'),
shareButDie(1),
);
//#endregion
//#region Loading
const chunkedCursor$ = cursor$.pipe(
chunk(pageSize),
);
const loadInputs$ = whenAny(inputs$, chunkedCursor$).pipe(
distinctUntilChanged(loadInputsSame),
map(([inputs, idx]) => [...inputs, idx]),
tag('loadInputs'),
shareButDie(1)
);
const loadResult$ = loadInputs$.pipe(
loadCollectionContents({ windowSize: 2 * pageSize }),
tag('loadResult'),
);
const loading$ = merge(loadInputs$, loadResult$).pipe(
activity(),
);
//#endregion
//#region Cache watcher
const cacheFromMonitor$ = inputs$.pipe(
watchCollectionCache({
cursor$,
pageSize,
debouncePeriod,
}),
tag('cacheFromMonitor'),
);
const payload$ = combineLatest(cacheFromMonitor$, totalMatches$).pipe(
debounceTime(debouncePeriod),
map((inputs) => buildPayload(...inputs)),
tag('payload'),
);
//#endregion
return { payload$, scrolling$, loading$ };
}
function getIndexFromPos(pos, totalMatches) {
const { cursor = null, key = null } = pos;
if (key !== null) {
return key;
}
if (cursor !== null) {
return Math.round(Number(cursor) * totalMatches);
}
return 0;
}
function buildPayload(result, totalMatches) {
const { contents } = result;
const topRows = contents.length ? contents[0].element_index : 0;
const bottomRows = Math.max(0, totalMatches - contents.length - topRows);
return { ...result, topRows, bottomRows, totalMatches };
}
@@ -0,0 +1,38 @@
import { of } from "rxjs";
import { map } from "rxjs/operators";
import { aggregateCacheUpdates } from "../ContentProvider";
import { monitorCollectionContent } from "../../caching";
import { SEEK } from "../../caching/enums";
import { SearchParams } from "../../model";
// import { show } from "utils/observable";
// prettier-ignore
export const watchCollection = (cfg = {}) => elementIndex$ => {
const {
dsc,
filters = new SearchParams(),
pageSize = SearchParams.pageSize,
debug = false,
keyField = "element_index",
keyDirection = SEEK.ASC,
...otherConfig
} = cfg;
// builds a monitor observable based on a element_index
const monitor = (idx) => of([dsc.contents_url, filters, idx]).pipe(
monitorCollectionContent({ pageSize, debug, keyField }),
);
// handles the plumbing for observing the query (monitor) and collecting the output into an
// aggregate ordered list focused around the current target key (element_index in this case)
return elementIndex$.pipe(
map(val => parseInt(val)),
aggregateCacheUpdates(monitor, {
keyField,
keyDirection,
pageSize,
debug,
...otherConfig,
}),
);
};
@@ -0,0 +1,125 @@
import { of, timer } from "rxjs";
import { takeUntil, share } from "rxjs/operators";
import { ObserverSpy } from "@hirez_io/observer-spy";
import { watchCollection } from "./watchCollection";
import { cacheCollectionContent, bulkCacheDscContent, wipeDatabase } from "../../caching";
import { testCollectionContent, testCollection } from "../../test/testCollection";
import { untilNthEmission } from "jest/helpers";
// need to mock monitorHistoryContent which is instantiated inside the worker,
// but we can't actually fire up threads.js in a unit test because you can't
// call expose() on the main thread, so we mock the adapter functions which
// generate observables from the worker.
jest.mock("app");
jest.mock("../../caching");
const debouncePeriod = 250;
const safetyTimeout = 1000;
const pageSize = 5;
const pluckAll = (arr, prop) => arr.map((o) => o[prop]);
let cachedCollectionContent;
beforeEach(async () => {
await wipeDatabase();
cachedCollectionContent = await bulkCacheDscContent(testCollectionContent, true);
// const cachedIds = pluckAll(cachedCollectionContent, "_id");
// console.log(cachedIds);
});
afterEach(async () => {
await wipeDatabase();
});
describe("watchCollection", () => {
test("should observe pre-inserted content on the initial emission", async () => {
// the srource stream for the operator is an element_index
const watcher$ = of(0).pipe(
watchCollection({
dsc: testCollection,
pageSize,
debouncePeriod,
debug: false,
}),
takeUntil(timer(safetyTimeout))
);
// spy on output of observable, wait for it to end
const spy = new ObserverSpy();
watcher$.subscribe(spy);
await spy.onComplete();
expect(spy.receivedNext()).toBe(true);
expect(spy.receivedComplete()).toBe(true);
expect(spy.getValuesLength()).toBe(1);
const payload = spy.getFirstValue();
{
const { startKey, startKeyIndex, targetKey, contents } = payload;
// at the top of the list, should get the match (element_index = 0) + 2 pages
expect(startKey).toEqual(0);
expect(startKeyIndex).toEqual(0);
expect(targetKey).toEqual(0);
expect(Array.isArray(contents)).toBe(true);
// should get back the exact match (element_index = 0, the following page, + 1
// additional page of buffer-room after the current page
const returnedIndexes = pluckAll(contents, "element_index");
// console.log(returnedIndexes);
expect(contents.length).toBeGreaterThanOrEqual(2 * pageSize);
expect(returnedIndexes).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
}
});
test("should observe subsequent updates", async () => {
// the srource stream for the operator is an element_index
const watcher$ = of(0).pipe(
watchCollection({
dsc: testCollection,
debouncePeriod,
pageSize,
}),
takeUntil(timer(safetyTimeout)),
share()
);
const spy = new ObserverSpy();
watcher$.subscribe(spy);
// After the first event, change some stuff on the first row
// need to delay a little to wait for first emissions to run through
// otherwise the debouncing will think the 2nd update is the same as the
await untilNthEmission(watcher$, 1);
const testChild = cachedCollectionContent[0];
testChild.foobar = 123;
const updateResult = await cacheCollectionContent(testChild, true);
// console.log("after update", updateResult);
expect(updateResult.foobar).toEqual(123);
expect(updateResult._rev).not.toEqual(testCollection._rev);
await spy.onComplete();
expect(spy.receivedNext()).toBe(true);
expect(spy.receivedComplete()).toBe(true);
expect(spy.getValuesLength()).toEqual(2);
const firstPayload = spy.getFirstValue();
{
const { contents } = firstPayload;
expect(Array.isArray(contents)).toBe(true);
// no foobar because this happened before the update
const foobars = pluckAll(contents, "foobar");
expect(foobars.every((val) => val === undefined)).toBe(true);
}
const lastPayload = spy.getLastValue();
{
const { contents } = lastPayload;
expect(Array.isArray(contents)).toBe(true);
// should have a foobar
const foobars = pluckAll(contents, "foobar");
expect(foobars.every((val) => val === undefined)).toBe(false);
}
});
});
@@ -1,96 +0,0 @@
import { combineLatest } from "rxjs";
import { map, switchMap, scan, distinctUntilChanged, catchError } from "rxjs/operators";
import { tag } from "rxjs-spy/operators/tag";
import { chunk } from "../../caching/operators/chunk";
import { monitorDscQuery } from "../../caching";
import { processContentUpdate, newUpdateMap, buildContentResult, getKeyForUpdateMap } from "../aggregation";
import { SearchParams } from "../../model";
// prettier-ignore
export const watchCollectionCache = (cfg = {}) => input$ => {
const {
cursor$,
pageSize = SearchParams.pageSize,
monitorChunk = 5 * pageSize,
monitorPageSize = 5 * monitorChunk,
inputDebounce = 250,
// outputDebounce = inputDebounce,
keyField = "element_index",
keyDirection = "asc",
} = cfg;
const getKey = getKeyForUpdateMap(keyField);
const aggregator = processContentUpdate({ getKey });
const summarize = buildContentResult({ pageSize, keyDirection, getKey });
const contentMap$ = input$.pipe(
switchMap(([{contents_url}, params]) => {
const chunkedCursor$ = cursor$.pipe(
chunk(monitorChunk, true),
distinctUntilChanged(),
);
const monitorInput$ = chunkedCursor$.pipe(
map((idx) => [contents_url, params, idx]),
tag('monitorInputs'),
);
const request$ = monitorInput$.pipe(
map(buildDscChildrenSelector(monitorPageSize)),
tag('request'),
);
const monitorOutput$ = request$.pipe(
monitorDscQuery({ inputDebounce }),
tag('monitorOutput'),
);
// aggregate results in a map for each set of id + params
const updateMap$ = monitorOutput$.pipe(
scan(aggregator, newUpdateMap()),
);
return updateMap$;
}),
);
const contentWindow$ = combineLatest([ contentMap$, cursor$ ]).pipe(
// debounceTime(outputDebounce),
map(summarize),
tag('contentWindow'),
);
return contentWindow$.pipe(
catchError(err => {
console.warn("Error in watchHistoryContents", err);
throw err;
})
);
};
// filters currently unused, but they could be if we add some filter UI
export const buildDscChildrenSelector = (pageSize) => (inputs) => {
// console.log("buildDscChildrenSelector", pageSize, inputs);
// eslint-disable-next-line no-unused-vars
const [parent_url, filters, cursor] = inputs;
const lowerLimit = Math.max(0, cursor - pageSize);
const request = {
selector: {
_id: { $gte: `${parent_url}-${lowerLimit}` },
parent_url: { $eq: parent_url },
},
sort: [{ _id: "asc" }],
limit: pageSize,
index: {
fields: ["_id"],
sort: [{ _id: "asc" }],
name: "collection contents by parent_url and element_index ascending",
ddoc: "idx-dsc-contents-by-url-and-element-asc",
},
};
return request;
};
@@ -1,114 +0,0 @@
import { of, isObservable, timer } from "rxjs";
import { take, takeUntil, share } from "rxjs/operators";
import { ObserverSpy } from "@hirez_io/observer-spy";
// import { wait } from "jest/helpers";
import { SearchParams } from "../../model/SearchParams";
import { watchCollectionCache } from "./watchCollectionCache";
import { cacheCollectionContent, bulkCacheDscContent, wipeDatabase } from "../../caching";
import rawCollection from "../../test/json/collectionContent.json";
jest.mock("app");
// need to mock monitorHistoryContent which is instantiated inside the worker,
// but we can't actually fire up threads.js in a unit test because you can't
// call expose() on the main thread, so we mock the adapter functions which
// generate observables from the worker.
jest.mock("../../caching");
beforeEach(wipeDatabase);
afterEach(wipeDatabase);
// prettier-ignore
describe("watchCollectionContents", () => {
describe("single layer collection", () => {
let children;
const testTimeout = 3000;
const parent_url = "/foo";
const cursor$ = of(0);
const fakeDscParent = { contents_url: parent_url };
const input$ = of([fakeDscParent, new SearchParams()]);
// insert a list of collection contents
beforeEach(async () => {
const rawChildren = rawCollection.map((props) => ({ ...props, parent_url }));
children = await bulkCacheDscContent(rawChildren, true);
});
test("sanity check inputs", () => {
expect(children).toBeDefined();
expect(Array.isArray(children)).toBe(true);
expect(children.length).toEqual(rawCollection.length);
expect(children.every((doc) => doc.parent_url == parent_url)).toBe(true);
});
test("should observe pre-inserted collection content", async () => {
const watcher$ = input$.pipe(
watchCollectionCache({ cursor$, pageSize: 5 }),
take(1),
takeUntil(timer(testTimeout))
);
expect(isObservable(watcher$)).toBe(true);
// spy on output of observable, wait for it to end
const spy = new ObserverSpy();
watcher$.subscribe(spy);
await spy.onComplete();
expect(spy.receivedNext()).toBe(true);
expect(spy.receivedComplete()).toBe(true);
const { startKey, contents } = spy.getFirstValue();
expect(startKey).toEqual(0);
expect(Array.isArray(contents)).toEqual(true);
expect(contents.length).toEqual(rawCollection.length);
// show(contents);
});
test("should observe subsequent updates", async () => {
const watcher$ = input$.pipe(
watchCollectionCache({
cursor$,
pageSize: 100,
inputDebounce: 0,
outputDebounce: 100,
}),
take(2),
takeUntil(timer(2000)),
share()
);
expect(isObservable(watcher$)).toBe(true);
// After the first event, change some stuff
const firstWatcherEvent$ = watcher$.pipe(take(1));
firstWatcherEvent$.subscribe(async () => {
// Update one of the rows
const testCollection = children[0];
testCollection.foobar = 123;
const updateResult = await cacheCollectionContent(testCollection, true);
expect(updateResult.foobar).toEqual(123);
expect(updateResult._rev).not.toEqual(testCollection._rev);
});
// spy on output of observable, wait for it to end
const spy = new ObserverSpy();
watcher$.subscribe(spy);
await spy.onComplete();
expect(spy.receivedNext()).toBe(true);
expect(spy.receivedComplete()).toBe(true);
expect(spy.getValuesLength()).toEqual(2);
const { contents } = spy.getLastValue();
expect(Array.isArray(contents)).toBe(true);
expect(contents.length).toEqual(children.length);
expect(contents[0].foobar).toEqual(123);
});
});
});
@@ -1,159 +0,0 @@
import { NEVER, BehaviorSubject } from "rxjs";
import { SearchParams } from "../model/SearchParams";
// dumb math util
export const clamp = (val, [bottom, top]) => Math.max(bottom, Math.min(top, val));
// simple comparators
export const isDefined = (val) => val !== null && val !== undefined;
// defined, number and finite
export const isValidNumber = (val) => {
return isDefined(val) && !isNaN(val) && isFinite(val);
};
// validate output variables before rendering
export const validPayload = ({ topRows, bottomRows, totalMatches }) => {
return isValidNumber(topRows) && isValidNumber(bottomRows) && isValidNumber(totalMatches);
};
// comparator for distinct() style operators inputs are an array of [id, SearchParams]
export const inputsSame = ([a0, a1], [b0, b1]) => {
return a0 == b0 && SearchParams.equals(a1, b1);
};
export const scrollPosEquals = (a, b) => {
return a.cursor === b.cursor && a.key === b.key;
};
export const ContentProvider = {
props: {
parent: { type: Object, required: true },
pageSize: { type: Number, default: SearchParams.pageSize },
debouncePeriod: { type: Number, default: 100 },
disableWatch: { type: Boolean, default: false },
disableLoad: { type: Boolean, default: false },
debug: { type: Boolean, default: false },
},
computed: {
busy() {
return this.loading || this.scrolling;
},
},
data() {
return {
payload: {},
params: new SearchParams(),
loading: false,
scrolling: false,
};
},
watch: {
params(newParams, oldParams) {
if (!SearchParams.equals(newParams, oldParams)) {
this.resetScrollPos();
}
},
},
created() {
this.initParams();
this.initScrollPos();
const { payload$, loading$, scrolling$ } = this.initStreams();
this.listenTo(scrolling$, (val) => (this.scrolling = val));
this.listenTo(loading$, (val) => (this.loading = val));
// render output
if (!this.disableWatch) {
this.listenTo(payload$, {
next: (payload) => this.setPayload(payload),
error: (err) => console.warn("error in cache$", err),
complete: () => console.warn("why did cache$ complete?"),
});
}
},
methods: {
initParams() {
this.params$ = new BehaviorSubject(new SearchParams());
this.listenTo(this.params$, (val) => (this.params = val));
},
initScrollPos() {
this.scrollPos$ = new BehaviorSubject({ cursor: 0.0, key: null });
},
resetScrollPos() {
this.scrollPos$.next({ cursor: 0.0, key: null });
},
initStreams() {
console.warn("Override initStreams in ContentProvider");
return { cache$: NEVER, loader$: NEVER, loading$: NEVER, scrolling$: NEVER };
},
/**
* Handler for the scroller to update its position, either by key or
* cursor (0-1 value representing how far down it is)
* @param {object} Object consisting of key and cursor props
*/
setScrollPos({ cursor = 0.0, key = null } = {}) {
if (isValidNumber(cursor)) {
this.scrollPos$.next({ cursor, key });
}
},
/**
* After bulk operations there is a need to trigger a fresh load.
*/
manualReload() {
this.setScrollPos(this.scrollPos$.value);
},
/**
* Exposed method so child components can update the search parameters.
* @param {SearchParams} newParams Fresh search params
*/
updateParams(newParams) {
const val = newParams instanceof SearchParams ? newParams.clone() : new SearchParams(newParams);
this.params$.next(val);
},
/**
* Render cache observable results to the payload property. It's best to
* set everything at once to avoid multiple render passes.
* @param {object} result Cache observable response
*/
setPayload(newPayload = {}) {
this.$set(this, "payload", newPayload);
},
},
render() {
return this.$scopedSlots.default({
// actual content delivery
payload: this.payload,
// local vars/settings/props passthrough
loading: this.loading,
scrolling: this.scrolling,
busy: this.busy,
params: this.params,
pageSize: this.pageSize,
// update methods
handlers: {
updateParams: this.updateParams,
setScrollPos: this.setScrollPos,
manualReload: this.manualReload,
},
});
},
};
export default ContentProvider;
@@ -0,0 +1,127 @@
import { NEVER } from "rxjs";
import { SearchParams, ScrollPos } from "../../model";
import { isValidNumber } from "./helpers";
// import { reportPayload } from "../../test/providerTestHelpers";
// first emission, emitted when parent (history) or filters changes to reset the view
export const defaultPayload = {
contents: [],
targetKey: null,
startKey: null,
startKeyIndex: 0,
topRows: 0,
bottomRows: 0,
totalMatches: 0,
};
export const ContentProvider = {
props: {
parent: { type: Object, required: true },
params: { type: SearchParams, default: () => new SearchParams() },
pageSize: { type: Number, default: SearchParams.pageSize },
debouncePeriod: { type: Number, default: 1000 },
debug: { type: Boolean, default: false },
},
computed: {
busy() {
return this.loading || this.scrolling;
},
slotProps() {
return {
// actual content delivery
payload: this.payload,
// local vars/settings/props passthrough
scrollPos: this.scrollPos,
loading: this.loading,
scrolling: this.scrolling,
busy: this.busy,
params: this.params,
pageSize: this.pageSize,
// update methods
setScrollPos: this.setScrollPos,
manualReload: this.manualReload,
};
},
},
data() {
return {
payload: defaultPayload,
scrollPos: new ScrollPos(),
loading: false,
scrolling: false,
};
},
watch: {
params(newParams, oldParams) {
if (!SearchParams.equals(newParams, oldParams)) {
this.resetScrollPos();
}
},
},
created() {
this.params$ = this.watch$("params");
this.scrollPos$ = this.watch$("scrollPos");
const { payload$, loading$, scrolling$ } = this.initStreams();
this.listenTo(scrolling$, (val) => (this.scrolling = val));
this.listenTo(loading$, (val) => (this.loading = val));
// render output
this.listenTo(payload$, {
next: (payload) => this.setPayload(payload),
error: (err) => console.warn("error in cache$", err),
complete: () => console.warn("why did cache$ complete?"),
});
},
methods: {
resetScrollPos() {
this.scrollPos = ScrollPos.create();
},
initStreams() {
console.warn("Override initStreams in ContentProvider");
return { cache$: NEVER, loader$: NEVER, loading$: NEVER, scrolling$: NEVER };
},
/**
* Handler for the scroller to update its position, either by key or
* cursor (0-1 value representing how far down it is)
* @param {object} Object consisting of key and cursor props
*/
setScrollPos({ cursor = 0.0, key = null } = {}) {
if (isValidNumber(cursor)) {
this.scrollPos = ScrollPos.create({ cursor, key });
}
},
/**
* After bulk operations there is a need to trigger a fresh load.
*/
manualReload() {
this.setScrollPos(this.scrollPos$.value);
},
/**
* Render cache observable results to the payload property. It's best to
* set everything at once to avoid multiple render passes.
* @param {object} result Cache observable response
*/
setPayload(props = {}) {
const payload = Object.assign({}, defaultPayload, props);
this.$set(this, "payload", payload);
},
},
render() {
return this.$scopedSlots.default(this.slotProps);
},
};
export default ContentProvider;
@@ -0,0 +1,106 @@
// Generalized bi-directional watch and aggregator.
// Looks at cache from starting index, then looks up and down
import { timer, throwError, from } from "rxjs";
import {
map,
debounceTime,
distinctUntilChanged,
groupBy,
reduce,
mergeMap,
switchMap,
withLatestFrom,
} from "rxjs/operators";
import { chunk, show, debounceBurst, shareButDie } from "utils/observable";
import { SearchParams } from "../../model/SearchParams";
import { processContentUpdate, newUpdateMap, buildContentResult, getKeyForUpdateMap } from "./aggregation";
import { SEEK } from "../../caching/enums";
import { reportPayload } from "components/History/test/providerTestHelpers";
// prettier-ignore
export const aggregateCacheUpdates = (monitor, cfg = {}) => (src$) => {
// monitor is a function that generates the update observable
if (!(monitor instanceof Function)) {
return throwError("monitor should be a function in aggregateCacheUpdates");
}
const {
// number of rows we roughly expect to be in the window
pageSize = SearchParams.pageSize,
// input and output throttling
debouncePeriod = 250,
// field and key order for the contents
keyField = "hid",
keyDirection = SEEK.DESC,
// interval in which we create new monitors
// if we chunk the inputs then we won't make as many
// might be easier on memory
inputChunk = Math.floor(pageSize / 2),
// reduces duplicate updates to the skiplist
// which can be expensive
updateGroupLifetime = 2 * debouncePeriod,
debug = false,
} = cfg;
// function that extracts the key from a document
const getKey = getKeyForUpdateMap(keyField);
// incoming target key
const targetKey$ = src$.pipe(shareButDie(1));
// avoids creating a new monitor for every single key emission
// chunk to ceiling if descending, floor if ascending
const monitorInputKey$ = targetKey$.pipe(
chunk(inputChunk, keyDirection == SEEK.DESC),
distinctUntilChanged(),
show(debug, (key) => console.log("monitorInputKey", key)),
);
// incoming stream of cache updates
// Monitor generation function is passed in via config so we can use it against different types of queries
const cacheUpdates$ = monitorInputKey$.pipe(
switchMap(monitor),
show(debug, (change) => console.log("monitor output", change)),
);
// groups updates by skiplist key and debounces those
// streams individually so we're not pointlessly updating
// when we're just going to change it again shortly
const groupTimeout = () => timer(updateGroupLifetime);
const groupedUpdates$ = cacheUpdates$.pipe(
groupBy(update => update.key, undefined, groupTimeout),
mergeMap(updateByKey$ => updateByKey$.pipe(
debounceTime(0.25 * debouncePeriod),
)),
debounceBurst(debouncePeriod),
show(debug, changeBurst => console.log('burst', changeBurst.map(o => o.key))),
);
// aggregates changes into a skiplist.
const storage = newUpdateMap();
const currentMap$ = groupedUpdates$.pipe(
mergeMap(updateList => from(updateList).pipe(
reduce(processContentUpdate, storage)
)),
show(debug, () => console.log('grouped update burst done')),
);
const contentListInputs$ = currentMap$.pipe(
withLatestFrom(targetKey$),
);
// query skiplist with scroll position (transformed into a key from the skiplist)
// to produce list of nearby content
const contentList$ = contentListInputs$.pipe(
map(buildContentResult({ pageSize, keyDirection, getKey })),
show(debug, payload => reportPayload(payload, { indexKey: keyField, label: "aggregateCacheUpdates payload" })),
);
return contentList$;
};
@@ -0,0 +1,158 @@
/**
* Functions for aggregating incoming cache updates for presentation to the
* scroller. As cache changes roll in, the updates are summarized and sorted in
* a SkipList for fast, ordered retrieval.
*
* TODO: PouchDB has similar aggregation capabilities but when last I looked they
* were too difficult to get working properly with dynamically changing queries.
* I feel like this is the duty of the caching layer, however, and we should
* investigate again.
*/
import SkipList from "proper-skip-list";
import { pipe, take, filter } from "iter-tools/es2018";
import { SEEK } from "../../caching/enums";
import { SearchParams } from "../../model";
// utils for buildContentResult
const distance = (a, b) => Math.abs(+a - +b);
export const buildContentResult = (cfg = {}) => {
const { keyDirection = SEEK.ASC, pageSize = SearchParams.pageSize } = cfg;
if (!SEEK.isValid(keyDirection)) {
throw new Error(`buildContentResult: invalid sort direction: ${keyDirection}`);
}
return ([updateMap, targetKey]) => {
// console.log("buildContentResult", updateMap, rawTargetKey, typeof targetKey);
if (updateMap === undefined) {
throw new Error("missing skiplist");
}
if (targetKey === undefined) {
throw new Error("missing target key");
}
// make sure key is an int
if (!Number.isFinite(targetKey)) {
console.warn("Target key should be numeric", updateMap, targetKey);
throw new Error("buildContentResult: targetKey must be a numeric value", targetKey);
}
const { matchingValue, desc, asc } = updateMap.findEntries(targetKey);
// We want 3 whole pages, 1 behind us, the one we're on, and 1 ahead of us, this means
// we have to figure out what "behind" and "in front" mean based on the key direction
// if the keys are ascending, "down" the list is the asc iterator
// if the keys are ascending, "up" the list is the desc iterator
const isAscending = keyDirection == SEEK.ASC;
const topIterator = isAscending ? desc : asc;
const bottomIterator = isAscending ? asc : desc;
const topFilter = pipe(
filter(([k]) => (isAscending ? k < targetKey : k > targetKey)),
take(pageSize)
);
const bottomFilter = pipe(
filter(([k]) => (isAscending ? k > targetKey : k < targetKey)),
take(2 * pageSize)
);
const top = Array.from(topFilter(topIterator));
const bottom = Array.from(bottomFilter(bottomIterator));
// assemble contents array by combining the results
const match = matchingValue ? [[targetKey, matchingValue]] : [];
const contentEntries = [...top.reverse(), ...match, ...bottom];
// startKey is the closest key in the result set to the requested targetKey
const { startKey, startKeyIndex } = findClosestEntry(targetKey, contentEntries);
const contents = contentEntries.map(([k, v]) => v);
return {
// list of contents from the skiplist around the targetKey
contents,
// original value used to query the skiplist, should be in
// the middle of the contents list
targetKey,
// closest key to the targetKey that actually exists in the slice
startKey,
// index of the closest row matching the targetKey, also should
// be in the middle of the contents
startKeyIndex,
};
};
};
/**
* Finds closest of keys by measuring numeric distance from targetKey
*
* @param {String|Number} targetKey Key to match
* @param {String|Number} entries content entries from the map i.e. list of [key,val]
*
* @return {Object} startKey, startKeyIndex
*/
const findClosestEntry = (targetKey, entries = []) => {
const startingVal = { startKey: null, startKeyIndex: -1, distance: Infinity };
return entries
.filter((entry) => Array.isArray(entry))
.reduce((result, entry, idx) => {
const [key] = entry;
const myDistance = distance(targetKey, key);
return myDistance < result.distance ? { startKey: key, startKeyIndex: idx, distance: myDistance } : result;
}, startingVal);
};
/**
* Fresh update map, gets built when inputs change
*/
export const newUpdateMap = () => {
return new SkipList();
};
/**
* Generates a scan function for consuming updates from the pouchdb-live-find.
* Adds or removes incoming docs from the skiplist
*/
export const processContentUpdate = (resultMap, update) => {
const { doc, key, match } = update;
// make sure key is an int
const storageKey = parseInt(key);
if (match) {
resultMap.upsert(storageKey, doc);
} else if (resultMap.has(storageKey)) {
resultMap.delete(storageKey);
}
return resultMap;
};
// Configurable function returns a key for the updateMap from a doc during upate operations
// aggregate results in a map for each set of id + params
export const getKeyForUpdateMap = (keyField) => (doc) => {
let rawKey;
if (keyField in doc) {
rawKey = doc[keyField];
} else {
// won't have a full doc on a delete, but it's embeedded in the id
const parts = doc._id.split("-");
rawKey = parseInt(parts[1]);
}
const result = parseInt(rawKey);
if (isNaN(result)) {
console.warn("Non integer key for document", doc);
throw new Error("Key for update map should be an integer");
}
return result;
};
@@ -1,14 +1,18 @@
import SkipList from "proper-skip-list";
import { buildContentResult } from "./aggregation";
import { SEEK } from "../../caching/enums";
jest.mock("app");
jest.mock("../../caching");
describe("buildContentResult", () => {
describe("query history content from aggregate storage", () => {
const updateList = new SkipList();
// history content is keyed by hid, descending order
const keyDirection = "desc";
const keyDirection = SEEK.DESC;
const getKey = (item) => item.hid;
const pageSize = 3;
const pageSize = 2;
// sample content, indexed by hid
const hids = [100, 95, 61, 60, 50, 44, 41, 17, 1].sort();
@@ -19,13 +23,22 @@ describe("buildContentResult", () => {
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, startKey, targetKey } = summarize([updateList, queryKey]);
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(queryKey);
// returns the key we used to query the skiplist
expect(targetKey).toEqual(queryKey);
// the closest key to targetKey in the event that targetKey does not exist
expect(startKey).toEqual(queryKey);
// the array index of startKey
expect(startKeyIndex).toEqual(2);
// pageSize causes aggregation to back up one page from start of list, then delivers
// the previous page, the exact match, then 2 pages after the match
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([100, 95, 61, 60, 50, 44, 41]);
expect(resultKeys.length).toBeLessThanOrEqual(3 * pageSize + 1);
expect(resultKeys).toEqual([95, 61, 60, 50, 44, 41, 17]);
});
test("query key exists, query key at top", () => {
@@ -33,13 +46,15 @@ describe("buildContentResult", () => {
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, startKey, targetKey } = summarize([updateList, queryKey]);
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(queryKey);
expect(startKeyIndex).toEqual(0);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([100, 95, 61, 60]);
// match (top) + 2 pages after
expect(resultKeys).toEqual([100, 95, 61, 60, 50]);
});
test("query key exists, query key at bottom", () => {
@@ -47,13 +62,14 @@ describe("buildContentResult", () => {
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, startKey, targetKey } = summarize([updateList, queryKey]);
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(queryKey);
expect(startKeyIndex).toEqual(2);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([44, 41, 17, 1]);
expect(resultKeys).toEqual([41, 17, 1]);
});
test("query key doesn't exist, query key in middle", () => {
@@ -61,13 +77,14 @@ describe("buildContentResult", () => {
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, startKey, targetKey } = summarize([updateList, queryKey]);
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(50);
expect(startKeyIndex).toEqual(2);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([95, 61, 60, 50, 44, 41]);
expect(resultKeys).toEqual([61, 60, 50, 44, 41, 17]);
});
test("query key doesn't exist, query key near top", () => {
@@ -75,13 +92,14 @@ describe("buildContentResult", () => {
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, startKey, targetKey } = summarize([updateList, queryKey]);
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(100);
expect(startKeyIndex).toEqual(0);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([100, 95, 61, 60]);
expect(resultKeys).toEqual([100, 95, 61, 60, 50]);
});
test("query key doesn't exist, query key near bottom", () => {
@@ -89,13 +107,14 @@ describe("buildContentResult", () => {
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, startKey, targetKey } = summarize([updateList, queryKey]);
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(17);
expect(startKeyIndex).toEqual(1);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([44, 41, 17, 1]);
expect(resultKeys).toEqual([41, 17, 1]);
});
test("query key beyond upper limit", () => {
@@ -103,13 +122,14 @@ describe("buildContentResult", () => {
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, startKey, targetKey } = summarize([updateList, queryKey]);
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(100);
expect(startKeyIndex).toEqual(0);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([100, 95, 61]);
expect(resultKeys).toEqual([100, 95, 61, 60]);
});
test("query key below lower limit", () => {
@@ -117,13 +137,14 @@ describe("buildContentResult", () => {
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, startKey, targetKey } = summarize([updateList, queryKey]);
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(1);
expect(startKeyIndex).toEqual(1);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([41, 17, 1]);
expect(resultKeys).toEqual([17, 1]);
});
});
@@ -131,9 +152,9 @@ describe("buildContentResult", () => {
const updateList = new SkipList();
// collection content is keyed by element_index, ascending order
const keyDirection = "asc";
const keyDirection = SEEK.ASC;
const getKey = (item) => item.element_index;
const pageSize = 3;
const pageSize = 2;
// sample content, indexed by hid
const element_indexes = [1, 2, 3, 4, 5, 6, 7, 8, 9];
@@ -144,13 +165,14 @@ describe("buildContentResult", () => {
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, startKey, targetKey } = summarize([updateList, queryKey]);
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(queryKey);
expect(startKeyIndex).toEqual(2);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([2, 3, 4, 5, 6, 7, 8]);
expect(resultKeys).toEqual([3, 4, 5, 6, 7, 8, 9]);
});
test("query key exists, query key at top", () => {
@@ -158,13 +180,14 @@ describe("buildContentResult", () => {
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, startKey, targetKey } = summarize([updateList, queryKey]);
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(queryKey);
expect(startKeyIndex).toEqual(0);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([1, 2, 3, 4]);
expect(resultKeys).toEqual([1, 2, 3, 4, 5]);
});
test("query key exists, query key at bottom", () => {
@@ -172,13 +195,14 @@ describe("buildContentResult", () => {
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, startKey, targetKey } = summarize([updateList, queryKey]);
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(queryKey);
expect(startKeyIndex).toEqual(2);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([6, 7, 8, 9]);
expect(resultKeys).toEqual([7, 8, 9]);
});
test("query key doesn't exist, query key in middle", () => {
@@ -186,13 +210,14 @@ describe("buildContentResult", () => {
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, startKey, targetKey } = summarize([updateList, queryKey]);
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(4);
expect(startKeyIndex).toEqual(1);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([2, 3, 4, 5, 6, 7]);
expect(resultKeys).toEqual([3, 4, 5, 6, 7, 8]);
});
test("query key doesn't exist, query key near top", () => {
@@ -200,13 +225,14 @@ describe("buildContentResult", () => {
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, startKey, targetKey } = summarize([updateList, queryKey]);
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(1);
expect(startKeyIndex).toEqual(0);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([1, 2, 3]);
expect(resultKeys).toEqual([1, 2, 3, 4]);
});
test("query key doesn't exist, query key near bottom", () => {
@@ -214,13 +240,14 @@ describe("buildContentResult", () => {
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, startKey, targetKey } = summarize([updateList, queryKey]);
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(8);
expect(startKeyIndex).toEqual(1);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([6, 7, 8, 9]);
expect(resultKeys).toEqual([7, 8, 9]);
});
test("query key beyond upper limit", () => {
@@ -228,13 +255,14 @@ describe("buildContentResult", () => {
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, startKey, targetKey } = summarize([updateList, queryKey]);
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(1);
expect(startKeyIndex).toEqual(0);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([1, 2, 3]);
expect(resultKeys).toEqual([1, 2, 3, 4]);
});
test("query key below lower limit", () => {
@@ -242,13 +270,14 @@ describe("buildContentResult", () => {
const summarize = buildContentResult({ keyDirection, pageSize, getKey });
// choose a hid we know is in the results
const { contents, startKey, targetKey } = summarize([updateList, queryKey]);
const { contents, targetKey, startKey, startKeyIndex } = summarize([updateList, queryKey]);
expect(startKey).toEqual(9);
expect(startKeyIndex).toEqual(1);
expect(targetKey).toEqual(queryKey);
const resultKeys = contents.map(getKey);
expect(resultKeys).toEqual([7, 8, 9]);
expect(resultKeys).toEqual([8, 9]);
});
});
});
@@ -0,0 +1,56 @@
import { SearchParams } from "../../model";
// match object props
export const propMatch = (name) => (a, b) => {
return a[name] === b[name];
};
// comparator for distinct() style operators inputs are an array of [id, SearchParams]
export const inputsSame = ([a0, a1], [b0, b1]) => {
return a0 == b0 && SearchParams.equals(a1, b1);
};
// inputs + hid/cursor comparison
export const loadInputsSame = ([inputsA, cursorA], [inputsB, cursorB]) => {
return cursorA == cursorB && inputsSame(inputsA, inputsB);
};
// dumb math util
export const clamp = (val, [bottom, top]) => {
return Math.max(bottom, Math.min(top, val));
};
// simple comparators
export const isDefined = (val) => {
return val !== null && val !== undefined;
};
// defined, number and finite
export const isValidNumber = (val) => {
return isDefined(val) && !isNaN(val) && isFinite(val);
};
export const paginationEqual = (a, b) => {
return a.offset == b.offset && a.limit == b.limit;
};
/**
* Creates limit/offset from scroll position and data set size, adds padding. This pagination is
* used for the server-side content query where limit/offset will work
*
* @param {float} cursor 0-1 value representing how far down the list we are
* @param {int} totalMatches total server-side known matches for content query result
* @param {int} pageSize scaling parameter, number of rows in a "page"
*
* @return {object} limit/offset for use in content query
*/
export function buildPaginationWindow(cursor, totalMatches = 0, pageSize = SearchParams.pageSize) {
// this is the offset that the cursor is pointed at if we're estimating
const targetRow = Math.round(cursor * totalMatches);
// overlap one page before and one page after, 3 total pages queried
const offset = Math.max(0, targetRow - pageSize);
const limit = 3 * pageSize;
return { offset, limit, targetRow };
}
@@ -0,0 +1,18 @@
/**
* A component mixin and a set of observable utilities used in common by HicstoryContentProvider and
* CollectionContentProvider
*/
// base component definition for History & Collection content providers
export { ContentProvider, defaultPayload } from "./ContentProvider";
// aggreagtion functions collect an initial query and merge in subsequent updates which appear int
// he cache over time to present a unified list of results ordered by a relevant key
export { buildContentResult, newUpdateMap, processContentUpdate, getKeyForUpdateMap } from "./aggregation";
export { aggregateCacheUpdates } from "./aggregateCacheUpdates";
// configurable operators encapsulating shared behavior for content and collections
export { processContentStreams } from "./processContentStreams";
// utils
export { isValidNumber, buildPaginationWindow, paginationEqual } from "./helpers";
@@ -0,0 +1,61 @@
/**
* General plumbing for the 2 types of content providers. This funcction emits 3 observables:
* loading, scrolling, and payload. Payload's the most important and the operator that genrates the
* payload from the inputs is passed in as a parameter to this factory.
*/
import { debounceTime, distinctUntilChanged, share, startWith, switchMap } from "rxjs/operators";
import { activity, whenAny, show } from "utils/observable";
import { propMatch } from "./helpers";
import { SearchParams, ScrollPos } from "../../model";
import { defaultPayload } from "../ContentProvider";
import { BehaviorSubject } from "rxjs";
/**
* Takes incoming history, filters and scroll position and generates loading, scrolling and payload
* observable for rendering the content. payload$ is an observable that emits the data that should
* be displaye
*
* @param {function} payloadOperator observable operator that delivers the payload for this list
* @param {object} sources object containing params$, history$, scrollPos$ source observables
* @param {object} settings configuration parameter object
*
* @return {object} payload$, loading$, scrolling$ observables
*/
// prettier-ignore
export function processContentStreams(payloadOperator, sources = {}, settings = {}) {
const { debouncePeriod, debug = false } = settings;
// clean incoming source streams
const parent$ = sources.parent$.pipe(
distinctUntilChanged(propMatch("id"))
);
const params$ = sources.params$.pipe(
distinctUntilChanged(SearchParams.equals),
);
const pos$ = sources.scrollPos$.pipe(
debounceTime(debouncePeriod),
distinctUntilChanged(ScrollPos.equals),
);
const scrolling$ = sources.scrollPos$.pipe(
activity(200)
);
const loading$ = new BehaviorSubject(false);
// The actual loader, when history or params change we poll against the api and
// set up a cachewatcher, both of which are controlled by the scroll position
const loaderReset$ = whenAny(parent$, params$);
const payload$ = loaderReset$.pipe(
switchMap(([parent, filters]) => pos$.pipe(
show(debug, pos => {
console.clear();
console.log("processContentStreams: pos", JSON.stringify(pos));
}),
payloadOperator({ parent, filters, loading$, ...settings }),
startWith({ ...defaultPayload, placeholder: true }),
)),
share()
);
return { payload$, scrolling$, loading$ };
}
@@ -32,7 +32,8 @@ export default {
},
computed: {
dsc() {
return new DatasetCollection(this.raw);
const cleanObj = JSON.parse(JSON.stringify(this.raw));
return new DatasetCollection(cleanObj);
},
},
watch: {
@@ -1,67 +1,64 @@
/* eslint-disable no-unused-vars */
import { wait, mountRenderless, getLocalVue } from "jest/helpers";
import { mountRenderless, watchUntil, watchForChange } from "jest/helpers";
import { wipeDatabase } from "../../caching";
import { cacheContent, getCachedContent, cacheCollectionContent, getCachedCollectionContent } from "../../caching";
import { DatasetCollection } from "../../model/DatasetCollection";
import DscProvider from "./DscProvider";
// test data as root-level collections
import rootContents from "../../test/json/historyContentWithCollection.json";
// import rootContents from "../../test/json/historyContentWithCollection.json";
// test data for a nested collection
import nestedContents from "../../test/json/collectionContent.json";
// import nestedContents from "../../test/json/collectionContent.json";
import { testCollection, testNestedCollection } from "../../test/testCollection";
// Imports dependencies without firing up worker because that blows with Jest
jest.mock("app");
jest.mock("../../caching");
const mountProvider = async (Component, propsData) => {
const localVue = getLocalVue();
const wrapper = mountRenderless(Component, { localVue, propsData });
await wrapper.vm.$nextTick();
return wrapper;
};
beforeEach(wipeDatabase);
afterEach(wipeDatabase);
describe("DscProvider", () => {
let wrapper;
let rootCollection;
let nestedCollection;
const fakeContentUrl = "/foo/bar";
beforeEach(async () => {
// cache a root level collection as history content
const rawContent = rootContents[0];
const cacheResult = await cacheContent(rawContent, true);
rootCollection = new DatasetCollection(cacheResult);
// cache a nested collection as dscContent
const rawNested = nestedContents[0];
rawNested.parent_url = fakeContentUrl;
const nestedCacheResult = await cacheCollectionContent(rawNested, true);
nestedCollection = new DatasetCollection(nestedCacheResult);
});
afterEach(async () => {
if (wrapper) {
wrapper.destroy();
}
wrapper = undefined;
});
describe("monitors a root collection (direct child of a history)", () => {
let rootCollection;
beforeEach(async () => {
wrapper = await mountProvider(DscProvider, {
collection: rootCollection,
isRoot: true,
rootCollection = await cacheContent(testCollection, true);
expect(rootCollection).toBeDefined();
wrapper = await mountRenderless(DscProvider, {
propsData: {
collection: rootCollection,
isRoot: true,
},
});
expect(wrapper).toBeDefined();
const { newVal: dsc } = await watchForChange({ vm: wrapper.vm, propName: "dsc" });
expect(dsc._id).toEqual(rootCollection._id);
});
afterEach(() => {
rootCollection = undefined;
});
test("should successfully mount and emit the pre-cached data", () => {
expect(wrapper).toBeDefined();
const { _rev, cached_at, ...originalProps } = rootCollection;
const { _rev: new_rev, cached_at: new_cached_at, ...newProps } = wrapper.vm.dsc;
// compare output to input prop, should be the same because we didn't change anything
const root = new DatasetCollection(rootCollection);
const emitted = new DatasetCollection(wrapper.vm.dsc);
const { _rev, cached_at, ...originalProps } = root;
const { _rev: new_rev, cached_at: new_cached_at, ...newProps } = emitted;
expect(originalProps).toEqual(newProps);
});
@@ -72,10 +69,7 @@ describe("DscProvider", () => {
const fooVal = "abc123";
rootCollection.foo = fooVal;
const updatedRoot = await cacheContent(rootCollection, true);
// double-check it's really changed
const lookup = await getCachedContent(updatedRoot._id);
expect(lookup.foo).toEqual(fooVal);
expect(updatedRoot.foo).toEqual(fooVal);
// wait for next update then check the foo val
await wrapper.vm.$nextTick();
@@ -90,16 +84,37 @@ describe("DscProvider", () => {
});
describe("monitors nested collection", () => {
let nestedCollection;
const parent_url = "/foo";
beforeEach(async () => {
wrapper = await mountProvider(DscProvider, {
collection: nestedCollection,
isRoot: false,
// cache a collection root
nestedCollection = await cacheCollectionContent(testNestedCollection, true);
expect(nestedCollection).toBeDefined();
// mount and monitor
wrapper = await mountRenderless(DscProvider, {
propsData: {
collection: nestedCollection,
isRoot: false,
},
});
expect(wrapper).toBeDefined();
const { newVal: dsc } = await watchForChange({ vm: wrapper.vm, propName: "dsc" });
expect(dsc._id).toEqual(nestedCollection._id);
});
afterEach(() => {
nestedCollection = undefined;
});
test("should successfully mount and emit the pre-cached data", () => {
expect(wrapper).toBeDefined();
expect(wrapper.vm.dsc).toEqual(nestedCollection);
const orig = new DatasetCollection(nestedCollection);
const emitted = new DatasetCollection(wrapper.vm.dsc);
const { _rev, cached_at, ...originalProps } = orig;
const { _rev: new_rev, cached_at: new_cached_at, ...newProps } = emitted;
expect(originalProps).toEqual(newProps);
});
test("should emit updates as they occur", async () => {
@@ -108,23 +123,15 @@ describe("DscProvider", () => {
// write a new field to the collection
const fooVal = "abc123";
nestedCollection.foo = fooVal;
const updateResult = await cacheCollectionContent(nestedCollection);
expect(updateResult.updated).toBe(true);
// double-check it's really cached
const lookup = await getCachedCollectionContent(updateResult.id);
expect(lookup.foo).toEqual(fooVal);
const updateResult = await cacheCollectionContent(nestedCollection, true);
expect(updateResult.foo).toEqual(fooVal);
// wait for next update then check the foo val
await wait(500);
// await wait(500);
await wrapper.vm.$nextTick();
// all same props except _rev and cached_at and foo
const { cached_at, _rev, foo, ...newProps } = wrapper.vm.dsc;
expect(foo).toEqual(fooVal);
expect(old_cached_at).toBeLessThan(cached_at);
expect(old_rev).not.toEqual(_rev);
expect(newProps).toEqual(oldProps);
expect(wrapper.vm.dsc.foo).toEqual(fooVal);
});
});
});
@@ -1,5 +1,5 @@
import { ContentProvider } from "../ContentProvider";
import { processHistoryStreams } from "./processHistoryStreams";
import { ContentProvider, processContentStreams } from "../ContentProvider";
import { contentPayload } from "./contentPayload";
export default {
mixins: [ContentProvider],
@@ -24,11 +24,11 @@ export default {
methods: {
initStreams() {
const { disablePoll, debouncePeriod, pageSize, params$, scrollPos$ } = this;
const history$ = this.watch$("history");
const sources = { params$, history$, scrollPos$ };
const settings = { disablePoll, debouncePeriod, pageSize };
return processHistoryStreams(sources, settings);
const { disablePoll, debouncePeriod, pageSize, params$, scrollPos$, debug } = this;
const parent$ = this.watch$("history");
const sources = { params$, parent$, scrollPos$ };
const settings = { disablePoll, debouncePeriod, pageSize, debug };
return processContentStreams(contentPayload, sources, settings);
},
},
};
@@ -1,129 +1,86 @@
import { pipe } from "rxjs";
import { map } from "rxjs/operators";
import { History } from "../../model/History";
import { SearchParams } from "../../model/SearchParams";
import { shallowMount } from "@vue/test-utils";
import { watchUntil, getLocalVue } from "jest/helpers";
import { sameVueObj, payloadChange } from "../../test/providerTestHelpers";
import HistoryContentProvider from "./HistoryContentProvider";
import { SearchParams } from "../../model";
import { bulkCacheContent, wipeDatabase } from "../../caching";
import { getPropRange } from "../../caching/loadHistoryContents";
import { watchForChange, mountRenderless } from "jest/helpers";
import { getLocalVue } from "jest/helpers";
import { defaultPayload } from "../ContentProvider";
import { serverContent, testHistory, testHistoryContent } from "../../test/testHistory";
// import { reportPayload } from "../../test/providerTestHelpers";
//#region Test Data
import rawHistory from "../../test/json/History.json";
import rawHistoryContent from "../../test/json/historyContent.json";
// doctor up some sample content
const historyContent = rawHistoryContent.map((item) => {
item.history_id = rawHistory.id;
return item;
});
// set hid_counter to total length +1 because the stupid api can't count
const testHistory = new History({
...rawHistory,
hid_counter: historyContent[0].hid + 1,
});
//#endregion
//#region Mocking
// reads hids
const payloadHids = (payload) => payload.contents?.map((o) => o.hid) || [];
const localVue = getLocalVue();
// mocking
jest.mock("app");
jest.mock("../../caching");
import { loadContents } from "./loadContents";
jest.mock("./loadContents");
const loadContentsResults = historyContent;
const { min: minHid, max: maxHid } = getPropRange(loadContentsResults, "hid");
loadContents.mockImplementation((config) => {
return pipe(
map(([history, params, hid]) => {
return {
minContentHid: minHid,
maxContentHid: maxHid,
minHid: minHid,
maxHid: maxHid,
matchesUp: 0,
matchesDown: SearchParams.pageSize,
matches: SearchParams.pageSize,
totalMatchesUp: 0,
totalMatches: historyContent.length,
};
})
);
});
//#endregion
const payloadChange = async ({ vm, label = "payload change" }) => {
await watchForChange({ vm, propName: "payload", label });
return vm.payload;
};
const mountProvider = async (Component, propsData) => {
const localVue = getLocalVue();
const wrapper = mountRenderless(Component, { localVue, propsData });
await wrapper.vm.$nextTick();
return wrapper;
};
afterEach(() => wipeDatabase());
describe("HistoryContentProvider", () => {
let wrapper;
const defaultProps = {
parent: testHistory,
// debouncePeriod: 0,
bench: 5,
disableLoad: true,
};
beforeEach(async () => {});
afterEach(async () => {
if (wrapper) {
await wrapper.destroy();
}
await wipeDatabase();
});
// No loading, no watching, no polling, all output variables should be
// dependent solely upon input history
describe("blank initialization state", () => {
beforeEach(async () => {
await wipeDatabase();
wrapper = await mountProvider(HistoryContentProvider, defaultProps);
});
test("should expose all the update methods and default vars", () => {
// allows child components to update search params via event handler
expect(wrapper.vm.updateParams).toBeInstanceOf(Function);
// allows child scroller component to update the position in the history
// via an event handler
expect(wrapper.vm.setScrollPos).toBeInstanceOf(Function);
// when data comes in from the observables, these are the handlers that
// run in the subscription, so we can test the provider's logic without
// having to invoke the observables directly
expect(wrapper.vm.setPayload).toBeInstanceOf(Function);
});
test("should show an empty payload", async () => {
wrapper = await shallowMount(HistoryContentProvider, {
localVue,
propsData: {
parent: testHistory,
pageSize: 10,
debug: false,
debouncePeriod: 200,
},
scopedSlots: {
default() {},
},
});
// wait for default emit
await payloadChange({ vm: wrapper.vm });
expect(wrapper.vm.params).toEqual(new SearchParams());
expect(wrapper.vm.pageSize).toEqual(SearchParams.pageSize);
});
test("should expose update methods", async () => {
expect(wrapper.vm.setScrollPos).toBeInstanceOf(Function);
});
test("should show an empty payload", () => {
expect(SearchParams.equals(new SearchParams(), wrapper.vm.params)).toBe(true);
expect(sameVueObj(wrapper.vm.payload, defaultPayload));
});
});
describe("with pre-inserted content", () => {
beforeEach(async () => {
await wipeDatabase();
await bulkCacheContent(historyContent);
wrapper = await mountProvider(HistoryContentProvider, {
...defaultProps,
pageSize: 5,
// eslint-disable-next-line no-unused-vars
const cachedContent = await bulkCacheContent(testHistoryContent, true);
wrapper = await shallowMount(HistoryContentProvider, {
localVue,
propsData: {
parent: testHistory,
pageSize: 10,
debug: false,
debouncePeriod: 200,
},
scopedSlots: {
default() {},
},
});
// eslint-disable-next-line no-unused-vars
const spinUp = await watchUntil(wrapper.vm, function () {
const allDone = wrapper.vm.payload.contents.length > 0;
// console.log("done?", allDone);
return allDone;
});
});
@@ -132,16 +89,22 @@ describe("HistoryContentProvider", () => {
// returned content rows and the total matches
test("initial load, no scrolling", async () => {
await watchForChange({ vm: wrapper.vm, propName: "payload" });
const { contents, topRows, bottomRows, startKey, totalMatches } = wrapper.vm.payload;
// show ({ itemCount: contents.length, topRows, bottomRows, startKey, totalMatches });
const payload = wrapper.vm.payload;
// reportPayload(payload);
// test data on server
const allContent = serverContent();
// maximum hid in the fake server content
const maxServerHid = allContent[0].hid;
expect(payload).toBeDefined();
const { contents, topRows, bottomRows, startKey, totalMatches } = payload;
expect(topRows).toEqual(0);
expect(contents.length).toEqual(wrapper.vm.pageSize + 1, "wrong pagesize");
expect(contents.length).toBeGreaterThanOrEqual(2 * wrapper.vm.pageSize);
expect(bottomRows).toBeGreaterThan(0);
expect(bottomRows).toEqual(historyContent.length - contents.length);
expect(startKey).toEqual(maxHid);
expect(totalMatches).toEqual(historyContent.length);
expect(bottomRows).toEqual(allContent.length - contents.length);
expect(startKey).toEqual(maxServerHid);
expect(totalMatches).toEqual(allContent.length);
});
// When the scroller moves, we may end up over a known content row, in
@@ -150,11 +113,11 @@ describe("HistoryContentProvider", () => {
// bottomRows
test("scroll to known key", async () => {
await watchForChange({ vm: wrapper.vm, propName: "payload" });
const payload = wrapper.vm.payload;
// shift the scroller down to half way through that content list
// make sure it's visible and not hidden
const targetElement = historyContent[Math.floor(historyContent.length / 2)];
const targetElement = testHistoryContent[Math.floor(testHistoryContent.length / 2)];
expect(targetElement.visible).toBe(true);
expect(targetElement.deleted).toBe(false);
@@ -162,44 +125,36 @@ describe("HistoryContentProvider", () => {
expect(targetKey).toBeDefined();
expect(targetKey).toBeGreaterThan(0);
// set that scroll position, then look at what comes out
// show(targetKey);
// console.log("setting scroll pos to targetKey", targetKey);
wrapper.vm.setScrollPos({ key: targetKey });
// what we should get out is a range of content around targetKey and
// startKey should == targetKey because we know it's in the content
const { contents: secondContents, ...secondPayload } = await payloadChange({
vm: wrapper.vm,
label: "second dealie",
});
// show({ items: secondContents.length, ...secondPayload });
// need to wait for it to settle down
await await payloadChange({ vm: wrapper.vm });
// check to see if what we want is there
const scrollPayload = wrapper.vm.payload;
expect(payload).toBeDefined();
const { startKey } = scrollPayload;
const hids = payloadHids(scrollPayload);
// console.log("hids", hids);
// should get a window around 133, 133 should be the startKey
const hids = secondContents.map((o) => o.hid);
expect(hids).toContain(targetKey);
expect(secondPayload.startKey).toEqual(targetKey);
expect(startKey).toEqual(targetKey);
});
// This simulates dragging the scrollbar to a place in the history where
// there is no exact match of HID. The provider should still render
// contents around the targetHID, if they exist
test("should show results when we pick a HID by scroller height rather than exact match", async () => {
await watchForChange({ vm: wrapper.vm, propName: "payload" });
test("should show results when we pick a HID by scroller height", async () => {
// set scroller to non-existent hid
wrapper.vm.setScrollPos({ cursor: 0.52323 });
const { contents, startKey, targetKey, topRows, bottomRows, totalMatches } = await payloadChange({
vm: wrapper.vm,
label: "scroll cursor dealie",
});
const hids = contents.map((o) => o.hid);
// show({ hids, startKey, targetKey, topRows, bottomRows, totalMatches });
wrapper.vm.setScrollPos({ cursor: 0.52323, key: null });
const scrollPayload = await payloadChange({ vm: wrapper.vm });
const { contents, startKey, topRows, bottomRows, totalMatches } = scrollPayload;
const hids = payloadHids(scrollPayload);
expect(hids).toContain(startKey);
expect(startKey).toBeGreaterThan(0);
expect(targetKey).toBeGreaterThan(0);
expect(topRows).toBeGreaterThan(0);
expect(bottomRows).toEqual(totalMatches - contents.length - topRows);
});
@@ -0,0 +1,34 @@
// common mock implementation
// having a hard time making jest manually mock this properly
import { map } from "rxjs/operators";
import { getPropRange } from "../../../caching/loadHistoryContents";
import { serverContent } from "../../../test/testHistory";
export const loadContents = jest.fn().mockImplementation((config) => {
const { filters } = config;
return map((pagination) => {
const { offset, limit } = pagination;
// server side result set
const searchResults = serverContent(filters);
const { min: minHid, max: maxHid } = getPropRange(searchResults, "hid");
// window slice returned
const result = searchResults.slice(offset, offset + limit);
const { min: minContentHid, max: maxContentHid } = getPropRange(result, "hid");
return {
summary: {},
matches: result.length,
totalMatches: searchResults.length,
minHid,
maxHid,
minContentHid,
maxContentHid,
limit,
offset,
};
});
});
@@ -0,0 +1,258 @@
import { merge, partition, Observable, BehaviorSubject } from "rxjs";
import {
tap,
distinctUntilChanged,
map,
share,
withLatestFrom,
pluck,
publish,
mergeAll,
debounceTime,
window,
} from "rxjs/operators";
import { chunk, show } from "utils/observable";
import { isValidNumber } from "../ContentProvider";
import { SearchParams, CurveFit } from "../../model";
import { loadContents } from "./loadContents";
import { watchHistoryContents } from "./watchHistoryContents";
/**
* Observable operator accepts history, search filters as config values, then uses an observable
* scroll position to periodically poll the server for new data and watch the cache for
* corresponding updates for a window of content defined by the cfg props.
*
* @param {object} cfg history, filters, other operation parameters
* @return {Observable} Observable that emits payloads suitable for use in the scroller
*/
// prettier-ignore
export const contentPayload = (cfg = {}) => {
const {
parent: history,
filters = new SearchParams(),
pageSize = SearchParams.pageSize,
debouncePeriod = 250,
disablePoll = false,
debug = false,
loading$ = new BehaviorSubject(),
} = cfg;
// stats for this history + filters, accumulates and improves over successive polls
const totalMatches$ = new BehaviorSubject(0);
const cursorToHid$ = new BehaviorSubject(createCursorToHid(history));
const hidToTopRows$ = new BehaviorSubject(createHidToTopRows(history));
return publish((pos$) => {
// #region establish HID by either having exact value or estimating from scroll position
const [ noKey$, hasKey$ ] = partition(pos$, (pos) => pos.key === null);
const knownHid$ = hasKey$.pipe(
pluck('key')
);
// only have cursor height, need to make a guess
const estimatedHid$ = noKey$.pipe(
pluck('cursor'),
withLatestFrom(cursorToHid$),
map((inputs) => estimateHid(...inputs)),
map(hid => Math.round(hid)),
show(debug, hid => console.log("estimatedHid", hid)),
);
const hid$ = merge(knownHid$, estimatedHid$).pipe(
distinctUntilChanged(),
share(),
);
//#endregion
// #region server loading
const serverHid$ = hid$.pipe(
chunk(pageSize, true),
distinctUntilChanged(),
show(debug, hid => console.log("serverHid", hid)),
share(),
);
const serverLoad$ = serverHid$.pipe(
tap(() => loading$.next(true)),
loadContents({ history, filters, disablePoll, debug }),
share(),
);
//#endregion
// #region Cache monitor
// after a fresh server request has been made, wait for first response before allowing
// an input to the cache, avoids bounciness
const cacheHid$ = hid$.pipe(
window(serverHid$),
mergeAll(),
debounceTime(100),
);
const cacheMonitor$ = cacheHid$.pipe(
watchHistoryContents({ history, filters, pageSize, debouncePeriod, debug }),
show(debug, (result) => console.log("cacheMonitor.contents (hid)", result.contents.map(o => o.hid))),
);
const payload$ = cacheMonitor$.pipe(
withLatestFrom(totalMatches$, hidToTopRows$),
map(buildPayload(pageSize)),
tap(() => loading$.next(false)),
show(debug, (payload) => console.log("payload", payload)),
);
// #endregion
// #region serverLoad side-effects, keeps track of important stats via feedback loops
const newCursorToHid$ = serverLoad$.pipe(
withLatestFrom(cursorToHid$),
map(([result, fit]) => adjustCursorToHid(result, fit))
);
const newHidToTopRows$ = serverLoad$.pipe(
withLatestFrom(hidToTopRows$),
map(([result, fit]) => adjustHidToTopRows(result, fit))
);
const newMatches$ = serverLoad$.pipe(
pluck('totalMatches'),
map(val => parseInt(val)),
);
//#endregion
return new Observable((obs) => {
const sub = payload$.subscribe(obs);
// Tie stat feedback subscriptions to main payload sub
// Make sure not to combineLatest these subject accumulators or we'll
// end up with an infinite loop
sub.add(newCursorToHid$.subscribe(cursorToHid$));
sub.add(newHidToTopRows$.subscribe(hidToTopRows$));
sub.add(newMatches$.subscribe(totalMatches$));
return sub;
})
})
};
const estimateHid = (cursor, fit) => {
// do an estimate/retrieval
const result = fit.get(cursor, { interpolate: true });
if (isValidNumber(result)) {
return result;
}
// give them the top of the data
if (fit.hasData) {
console.log("invalid curve fit", result, cursor, fit.domain);
return fit.get(0);
}
// we're screwed
console.log("estimateHid no estimate available");
return undefined;
};
const adjustCursorToHid = (result, fit) => {
const { maxHid, minHid, maxContentHid, minContentHid, matches, totalMatchesUp, totalMatches } = result;
// set 0, 1 for min/max hids
fit.set(0, maxHid);
fit.set(1, minHid);
// find cursor for top & bottom of returned content
if (totalMatches > 0) {
fit.set(totalMatchesUp / totalMatches, maxContentHid);
fit.set((matches + totalMatchesUp) / totalMatches, minContentHid);
}
return fit;
};
const adjustHidToTopRows = (result, fit) => {
const {
// min and max of returned data
minContentHid,
maxContentHid,
// endpoints for all of history, that match filters
minHid,
maxHid,
// num matches up/down from request hid
matchesUp,
matchesDown,
matches,
// total matches up/down from request hid
totalMatchesUp,
totalMatches,
} = result;
if (matches > 0) {
fit.set(maxContentHid, totalMatchesUp - matchesUp);
fit.set(minContentHid, Math.max(0, totalMatchesUp + matchesDown - 1));
}
fit.set(maxHid, 0);
if (totalMatches > 0) {
fit.set(minHid, totalMatches - 1);
}
fit.domain = [minHid, maxHid];
return fit;
};
const buildPayload = (pageSize) => (inputs) => {
// cacheWatchResult is the value from the cache watcher
// hidToTopRows is a dataset for estimating toprows from HID
// since we can't possibly have that information at the time
// of the cache-read
const [cacheWatchResult, totalMatches, hidToTopRows] = inputs;
// contents is the list from the aggregator
// targetKey was the request value for the aggregation
const { contents = [] } = cacheWatchResult;
let topRows = 0;
let bottomRows = Math.max(0, totalMatches - contents.length - 1);
// missing rows above & below content
if (hidToTopRows.size && contents.length && totalMatches > pageSize) {
const maxHid = contents[0].hid;
const rowsAboveTop = hidToTopRows.get(maxHid, { interpolate: true });
topRows = Math.max(0, Math.round(rowsAboveTop));
bottomRows = Math.max(0, totalMatches - topRows - contents.length);
}
return { topRows, bottomRows, totalMatches, ...cacheWatchResult };
};
const createHidToTopRows = (history) => {
const fit = new CurveFit();
fit.domain = [0.0, +history.hidItems];
fit.xPrecision = 3;
fit.yPrecision = 3;
fit.set(history.hidItems, 0.0);
fit.set(0.0, history.hidItems);
return fit;
};
const createCursorToHid = (history) => {
const fit = new CurveFit();
fit.domain = [0.0, 1.0];
fit.xPrecision = 3;
fit.yPrecision = 2;
fit.set(0.0, history.hidItems);
fit.set(1.0, 1);
return fit;
};
@@ -0,0 +1,153 @@
import { BehaviorSubject, timer } from "rxjs";
import { takeUntil, share } from "rxjs/operators";
import { ObserverSpy } from "@hirez_io/observer-spy";
import { ScrollPos } from "../../model";
import { bulkCacheContent, wipeDatabase } from "../../caching";
import { contentPayload } from "./contentPayload";
import { loadContents } from "./loadContents";
import { serverContent, testHistory, testHistoryContent } from "../../test/testHistory";
import { untilNthEmission } from "jest/helpers";
// import { reportPayload } from "../../test/providerTestHelpers";
jest.mock("app");
jest.mock("../../caching");
jest.mock("./loadContents");
// Create a history and a set of filters then wire up a scrollPos to
// the contentPayload operator, check the payloads that come out
describe("contentPayload operator", () => {
let sub;
const safetyTimeout = 2000;
const disablePoll = true;
const debouncePeriod = 250;
beforeEach(async () => {
await wipeDatabase();
await bulkCacheContent(testHistoryContent, true);
});
afterEach(async () => {
if (sub) sub.unsubscribe();
await wipeDatabase();
});
// The first emission any time we subscribe to the payload should
// be the top of the history, this is because we do not yet have any
// way to gauge how big the history is, so our first emission is always
// from the top of the list, which is where we reset the scroller when
// the inputs change anyway
test("single emission, no scrolling, top of list", async () => {
const spy = new ObserverSpy();
const start = new ScrollPos();
const input$ = new BehaviorSubject(start);
const pageSize = 10;
// prettier-ignore
sub = input$.pipe(
contentPayload({
parent: testHistory,
pageSize,
disablePoll,
debouncePeriod
}),
takeUntil(timer(safetyTimeout))
).subscribe(spy);
await spy.onComplete();
// should finish
expect(spy.receivedComplete()).toBe(true);
// should call the loader once
expect(loadContents.mock.calls.length).toEqual(1);
// should only emit one result
expect(spy.receivedNext()).toBe(true);
expect(spy.getValues().length).toEqual(1);
// expect a payload with results from the top of the list (cursor = 0)
const { contents } = spy.getFirstValue();
expect(contents).toBeDefined();
expect(contents).toBeInstanceOf(Array);
expect(contents.length).toBeGreaterThanOrEqual(2 * pageSize);
const payloadHids = contents.map((c) => c.hid);
expect(payloadHids[0]).toEqual(testHistoryContent[0].hid);
});
// The first emission must be from the top of the list, but then
// the user moves the scroller down half-way. Should get results
// from the middle of the sample contents
test("move half way down the list", async () => {
const pageSize = 5;
// scroll position (cursor/key)
const pos$ = new BehaviorSubject(ScrollPos.create());
// subscribe to payload emitter
// first emission needs to be at top of list
const payload$ = pos$.pipe(
contentPayload({
parent: testHistory,
pageSize,
disablePoll,
}),
takeUntil(timer(safetyTimeout)),
share()
);
const spy = new ObserverSpy();
sub = payload$.subscribe(spy);
// wait until first emission, then move scroller
await untilNthEmission(payload$, 1);
pos$.next(ScrollPos.create({ cursor: 0.5 }));
await spy.onComplete();
expect(spy.receivedComplete()).toBe(true);
expect(spy.receivedNext()).toBe(true);
// spy.getValues().forEach(reportPayload);
// should call the loader once
expect(loadContents.mock.calls.length).toBeGreaterThan(0);
const serverResults = serverContent();
const serverResultHids = serverResults.map((o) => o.hid);
const initialPayload = spy.getFirstValue();
{
const { contents, startKey, startKeyIndex, topRows, bottomRows, totalMatches } = initialPayload;
const listLength = 2 * pageSize;
const payloadHids = contents.map((o) => o.hid);
const historyHids = serverResultHids.slice(startKeyIndex, contents.length);
expect(contents).toBeDefined();
expect(contents).toBeInstanceOf(Array);
expect(contents.length).toBeGreaterThanOrEqual(listLength);
expect(startKey).toEqual(serverResultHids[0]);
expect(startKeyIndex).toEqual(0);
expect(topRows).toEqual(0);
expect(totalMatches).toEqual(serverResults.length);
expect(payloadHids).toEqual(historyHids);
expect(topRows + bottomRows + contents.length).toEqual(totalMatches);
}
const secondPayload = spy.getLastValue();
{
const { contents, startKey, startKeyIndex, topRows, bottomRows, totalMatches } = secondPayload;
const payloadHids = contents.map((o) => o.hid);
expect(contents).toBeDefined();
expect(contents).toBeInstanceOf(Array);
expect(topRows).toBeGreaterThan(0);
expect(totalMatches).toEqual(serverResults.length);
expect(startKeyIndex).toBeGreaterThan(0);
expect(payloadHids).toContain(startKey);
expect(topRows + bottomRows + contents.length).toEqual(totalMatches);
}
});
});
@@ -1,22 +1,37 @@
import { defer, of } from "rxjs";
import { repeat, switchMap, switchMapTo, startWith, debounceTime } from "rxjs/operators";
import { decay } from "utils/observable/decay";
import { concat, defer, of, throwError } from "rxjs";
import { delay, repeat, switchMap, switchMapTo, debounceTime, startWith } from "rxjs/operators";
import { decay } from "utils/observable";
import { monitorXHR } from "utils/observable/monitorXHR";
import { loadHistoryContents } from "../../caching";
import { SearchParams } from "../../model";
// prettier-ignore
export const loadContents = (cfg = {}) => {
const {
windowSize,
history,
filters,
initialInterval = 2 * 1000,
maxInterval = 10 * initialInterval,
disablePoll = false
disablePoll = false,
// debug = false,
// time between initial load and polling start
startingDelay = 5000,
windowSize = 2 * SearchParams.pageSize,
} = cfg;
return switchMap(([{ id }, params, hid]) => {
if (history === undefined) {
return throwError(new Error("Missing history in loadContents"));
}
if (filters === undefined) {
return throwError(new Error("Missing filters in loadContents"));
}
const { id } = history;
return switchMap((hid) => {
// a single history update
const singleLoad$ = of([id, params, hid]).pipe(
const singleLoad$ = of([id, filters, hid]).pipe(
loadHistoryContents({ windowSize })
);
@@ -39,6 +54,13 @@ export const loadContents = (cfg = {}) => {
switchMapTo(freshPoll$)
);
return disablePoll ? singleLoad$ : poll$;
});
// if we're polling, add a little delay between initial load and polling
// avoids pointless polling if the user is rapidly changing the view
const polling$ = of(true).pipe(
delay(startingDelay),
switchMapTo(poll$)
);
return disablePoll ? singleLoad$ : concat(singleLoad$, polling$);
})
};
@@ -1,224 +0,0 @@
import { combineLatest, merge, Subject } from "rxjs";
import { tap, pluck, map, distinctUntilChanged, withLatestFrom, debounceTime, share } from "rxjs/operators";
import { tag } from "rxjs-spy/operators/tag";
import { whenAny } from "utils/observable/whenAny";
import { activity } from "utils/observable/activity";
import { shareButDie } from "utils/observable/shareButDie";
import { chunk } from "../../caching/operators/chunk";
import { nth } from "utils/observable/nth";
import { SearchParams, CurveFit } from "../../model";
import { watchHistoryContents } from "./watchHistoryContents";
import { loadContents } from "./loadContents";
import { isValidNumber, inputsSame } from "../ContentProvider";
// inputs + hid/cursor comparison
export const loadInputsSame = ([inputsA, cursorA], [inputsB, cursorB]) => {
return cursorA == cursorB && inputsSame(inputsA, inputsB);
};
// prettier-ignore
export function processHistoryStreams(sources, settings) {
const { disablePoll, debouncePeriod, pageSize } = settings;
const { params$: rawParams$, history$: rawHistory$, scrollPos$ } = sources;
//#region Clean up, filter, debounce raw inputs
const history$ = rawHistory$.pipe(distinctUntilChanged((a, b) => a.id == b.id));
const params$ = rawParams$.pipe(distinctUntilChanged(SearchParams.equals));
const inputs$ = whenAny(history$, params$).pipe(share());
const scrolling$ = scrollPos$.pipe(activity());
//#endregion
//#region Total Matches, from history and returned ajax stats
const serverTotalMatches$ = new Subject();
const historyMatches$ = history$.pipe(pluck("hidItems"));
const totalMatches$ = merge(historyMatches$, serverTotalMatches$).pipe(distinctUntilChanged());
//#endregion
//#region Curve-fit objects, used to estimate hid & topRows
const updateCursorToHid$ = new Subject();
const updateHidToTopRows$ = new Subject();
// reset curve-fit data whenever inputs change
// same as history but emits each time inputs emits
const h$ = inputs$.pipe(nth(0));
const freshCursorToHid$ = h$.pipe(map(createCursorToHid));
const freshHidToTopRows$ = h$.pipe(map(createHidToTopRows));
// merge create + manual updates
const cursorToHid$ = merge(freshCursorToHid$, updateCursorToHid$).pipe(shareButDie(1));
const hidToTopRows$ = merge(freshHidToTopRows$, updateHidToTopRows$).pipe(shareButDie(1));
//#endregion
//#region Estimate HID from cursor + history + curveFit
const hid$ = whenAny(scrollPos$, cursorToHid$).pipe(
map(([pos, fit]) => (pos.key ? pos.key : estimateHid(pos, fit))),
map((hid) => parseInt(hid)),
distinctUntilChanged(),
tag('hid'),
shareButDie(1)
);
//#endregion
//#region Loading
const chunkedHid$ = hid$.pipe(chunk(pageSize, true));
const loadInput$ = whenAny(inputs$, chunkedHid$).pipe(
distinctUntilChanged(loadInputsSame),
map(([inputs, hid]) => [...inputs, hid]),
shareButDie(1)
);
const loadResult$ = loadInput$.pipe(
loadContents({ disablePoll, windowSize: 2 * pageSize })
);
// side-effects of loading
const loadEffects$ = loadResult$.pipe(
withLatestFrom(cursorToHid$, hidToTopRows$),
tap(([result, cursorToHid, hidToTopRows]) => {
// can result in infinite loops, adjust curve-data by reference
adjustCursorToHid(result, cursorToHid);
adjustHidToTopRows(result, hidToTopRows);
// updateHidToTopRows$.next();
serverTotalMatches$.next(result.totalMatches);
})
);
// loading flag
const loading$ = merge(loadInput$, loadEffects$).pipe(activity());
//#endregion
//#region Use inputs to read from cache
const cacheFromMonitor$ = inputs$.pipe(
watchHistoryContents({
hid$,
pageSize,
debouncePeriod,
})
);
const payload$ = combineLatest(cacheFromMonitor$, hidToTopRows$, totalMatches$).pipe(
debounceTime(debouncePeriod),
map((inputs) => buildPayload(...inputs, pageSize))
);
//#endregion
return { payload$, scrolling$, loading$ };
}
function estimateHid(scrollPos, cursorToHid) {
const { cursor, key } = scrollPos;
const fit = cursorToHid;
// do an estimate/retrieval
const result = fit.get(cursor, { interpolate: true });
if (isValidNumber(result)) {
return result;
}
// give them the top of the data
if (fit.hasData) {
console.log("invalid curve fit", result, cursor, key, fit.domain);
return fit.get(0);
}
// we're screwed
return undefined;
}
function adjustCursorToHid(result, fit) {
const { maxHid, minHid, maxContentHid, minContentHid, matches, totalMatchesUp, totalMatches } = result;
// set 0, 1 for min/max hids
fit.set(0, maxHid);
fit.set(1, minHid);
// find cursor for top & bottom of returned content
if (totalMatches > 0) {
fit.set(totalMatchesUp / totalMatches, maxContentHid);
fit.set((matches + totalMatchesUp) / totalMatches, minContentHid);
}
}
function adjustHidToTopRows(result, fit) {
const {
// min and max of returned data
minContentHid,
maxContentHid,
// endpoints for all of history, that match filters
minHid,
maxHid,
// num matches up/down from request hid
matchesUp,
matchesDown,
matches,
// total matches up/down from request hid
totalMatchesUp,
totalMatches,
} = result;
if (matches > 0) {
fit.set(maxContentHid, totalMatchesUp - matchesUp);
fit.set(minContentHid, Math.max(0, totalMatchesUp + matchesDown - 1));
}
fit.set(maxHid, 0);
if (totalMatches > 0) {
fit.set(minHid, totalMatches - 1);
}
fit.domain = [minHid, maxHid];
}
function buildPayload(result, hidToTopRows, totalMatches, pageSize) {
const { contents = [], startKey = null, targetKey } = result;
const fit = hidToTopRows;
let topRows = 0;
let bottomRows = Math.max(0, totalMatches - contents.length);
// missing rows above & below content
if (fit.size && contents.length && totalMatches > pageSize) {
const maxHid = contents[0].hid;
const rowsAboveTop = fit.get(maxHid, { interpolate: true });
topRows = Math.max(0, Math.round(rowsAboveTop));
bottomRows = Math.max(0, totalMatches - topRows - contents.length);
}
return { contents, startKey, targetKey, topRows, bottomRows, totalMatches };
}
function createHidToTopRows(history) {
const fit = new CurveFit();
fit.domain = [0.0, +history.hidItems];
fit.xPrecision = 3;
fit.yPrecision = 3;
fit.set(history.hidItems, 0.0);
fit.set(0.0, history.hidItems);
return fit;
}
function createCursorToHid(history) {
const fit = new CurveFit();
fit.domain = [0.0, 1.0];
fit.xPrecision = 3;
fit.yPrecision = 2;
fit.set(0.0, history.hidItems);
fit.set(1.0, 1);
return fit;
}
@@ -0,0 +1,73 @@
The history content provider is where the grunt-work of loading, polling and caching
happens. The output of the provider is a list of current data suitable for rendering in the companion Scroller component.
### Design
The API of the provider is a renderless Vue component. Its inputs are the history and the
currently selected filters. Internally, it tracks the user's current scroll position on the scrolling list then combines those 3 values to ultimately expose an object containing all the
currently-visible content for the history along with some other values that are useful
for the scroller to render the visible window of data.
#### Composition
```html static
<HistoryContentProvider
:parent="history"
:params="params"
v-slot="{ setScrollPos, loading, scrolling, payload }">
<!-- other UI elements that care about the current content,
filters editors, etc -->
<Scroller
:items="payload.contents"
:top-placeholders="payload.topRows"
:bottom-placeholders="payload.bottomRows"
:scroll-to="payload.startKey"
@scroll="setScrollPos">
<template v-slot="{ item, index }">
<!-- single item rendering -->
</template>
</Scroller>
</HistoryContentProvider>
```
#### History Content Provider exposed slot props (outputs)
```js
{
// a function to update the internal scroll position
setScrollPos: fn,
// current content window
payload: {
contents: []
topRows: 10
botttomRows: 100,
startKey: "123",
},
// flags
loading: true,
scrolling: false,
}
```
### Implementation
Because actually delivering that data is kind of a complex process (see dependency graph below) and has multiple goals, Vue's rather
simplistic observability system is not by itself be an ideal means to actually deliver the
constantly shifting payload value. Instead, we used RxJS and its more sophisticated, but admittedly
more complex API.
The goal of this section is to explain how the RxJS works inside the history content provider to
deliver the payload from the 3 changing inputs.
I believe it's easiest to follow RxJS streams by simply drawing a map of them. An rxjs operator is a
functional transformation of a source observable emitter into a new observable emitter and therefore
a combined composition of them (often called a stream) is essentially its own dependency tree. All
you have to do is connect the inputs to the outputs.
### RxJS Flowchart
@@ -1,77 +1,37 @@
import { of, combineLatest } from "rxjs";
import { map, switchMap, debounceTime, scan, distinctUntilChanged } from "rxjs/operators";
import { chunk } from "../../caching/operators/chunk";
import { of } from "rxjs";
import { map } from "rxjs/operators";
import { aggregateCacheUpdates } from "../ContentProvider";
import { monitorHistoryContent } from "../../caching";
import { SearchParams } from "../../model/SearchParams";
import { processContentUpdate, newUpdateMap, buildContentResult, getKeyForUpdateMap } from "../aggregation";
import { SEEK } from "../../caching/enums";
import { SearchParams } from "../../model";
// disable debugging
const tag = () => (src$) => src$;
/**
* Monitor history in the region of the cursor for the provided inputs
*
* @param {Observable} hid$ HID observable, HID of first row of scroller
* @param {Object} cfg Config object, see below
*/
// prettier-ignore
export const watchHistoryContents = (cfg = {}) => input$ => {
const {
hid$,
// final page size for rendered content
pageSize = SearchParams.pageSize,
// input and output throttling
debouncePeriod = 250,
outputDebounce = debouncePeriod,
// field and key order for the contents
export const watchHistoryContents = (cfg = {}) => hid$ => {
const {
history,
filters = new SearchParams(),
pageSize = SearchParams.pageSize,
debug = false,
keyField = "hid",
keyDirection = "desc",
// input hid chunk, reduces the number of monitors we create
// width of the hid window to observe in the cache
monitorChunk = 5 * pageSize,
monitorPageSize = 5 * monitorChunk,
keyDirection = SEEK.DESC,
...otherConfig
} = cfg;
// builds a monitor observable based on a hid
const monitor = (hid) => of([history.id, filters, hid]).pipe(
monitorHistoryContent({ pageSize, debug, keyField })
);
const getKey = getKeyForUpdateMap(keyField);
const aggregator = processContentUpdate({ getKey });
const summarize = buildContentResult({ pageSize, keyDirection, getKey });
const contentMap$ = input$.pipe(
switchMap(([{id}, params]) => {
// extremely wide chunking for the monitor since that's local
// and assembling a new monitor is expensive. New monitors will be
// created each time hid$ emits
const monitorInput$ = hid$.pipe(
chunk(monitorChunk, true),
distinctUntilChanged(),
map(hid => [id, params, hid]),
tag('watchHistoryContents-monitorInputs'),
);
const monitorOutput$ = monitorInput$.pipe(
switchMap(monitorInput => of(monitorInput).pipe(
monitorHistoryContent({
pageSize: monitorPageSize
}),
)),
scan(aggregator, newUpdateMap()),
);
return monitorOutput$;
// handles the plumbing for observing the query (monitor) and collecting the output into an
// aggregate ordered list focused around the current target key (hid in this case)
return hid$.pipe(
map(val => parseInt(val)),
aggregateCacheUpdates(monitor, {
keyField,
keyDirection,
pageSize,
debug,
...otherConfig
}),
);
// take a slice out of that content map corresponding to the current hid
const contentWindow$ = combineLatest([contentMap$, hid$]).pipe(
debounceTime(outputDebounce),
map(summarize),
tag('watchHistoryContents-contentWindow'),
);
return contentWindow$;
};
@@ -1,7 +1,7 @@
import { of, isObservable, timer } from "rxjs";
import { take, takeUntil } from "rxjs/operators";
import { ObserverSpy } from "@hirez_io/observer-spy";
// import { wait } from "jest/helpers";
import { untilNthEmission } from "jest/helpers";
import { SearchParams } from "../../model/SearchParams";
import { buildContentId } from "../../caching/db/observables";
@@ -17,31 +17,39 @@ import historyContent from "../../test/json/historyContent.json";
jest.mock("app");
jest.mock("../../caching");
beforeEach(wipeDatabase);
afterEach(wipeDatabase);
beforeEach(async () => {
await wipeDatabase();
// eslint-disable-next-line no-unused-vars
const cachedContent = await bulkCacheContent(historyContent, true);
// console.log(cachedContent.map((o) => o.hid));
});
afterEach(async () => {
await wipeDatabase();
});
// We're testing observables and we're assuming they complete,
// but we add a takeUntil() to each one in the event that something
// is wrong to avoid making the tests take forever
const safetyTimeout = 600;
const safetyTimeout = 1000;
describe("watchHistoryContents", () => {
const firstDoc = historyContent[0];
beforeEach(async () => await bulkCacheContent(historyContent));
describe("simple loading scenario", () => {
const hid$ = of(firstDoc.hid);
const fakeHistory = { id: firstDoc.history_id };
const input$ = of([fakeHistory, new SearchParams()]);
const history = { id: firstDoc.history_id };
const filters = new SearchParams();
const pageSize = 5;
test("should observe pre-inserted content on the initial emission", async () => {
const watcher$ = input$.pipe(
const watcher$ = hid$.pipe(
watchHistoryContents({
hid$,
pageSize: 5,
history,
filters,
pageSize,
debug: false,
}),
take(1),
takeUntil(timer(safetyTimeout))
);
@@ -54,54 +62,54 @@ describe("watchHistoryContents", () => {
expect(spy.receivedNext()).toBe(true);
expect(spy.receivedComplete()).toBe(true);
expect(spy.getValuesLength()).toBe(1);
const { startKey, contents } = spy.getFirstValue();
expect(startKey).toEqual(firstDoc.hid); // we know there should be an exactd match
// we know there should be an exactd match
expect(startKey).toEqual(firstDoc.hid);
expect(Array.isArray(contents)).toEqual(true);
expect(contents.length).toEqual(6); // we're at the top, should get 5 rows down + the match
// Top of list, match + 2 * pageSize
expect(contents.length).toBeGreaterThanOrEqual(2 * pageSize);
});
test("should observe subsequently updated data", async () => {
const watcher$ = input$.pipe(
const watcher$ = hid$.pipe(
watchHistoryContents({
hid$,
history,
filters,
debouncePeriod: 100,
pageSize: 5,
}),
// safeguard, sometimes the debouncing merges both results into one
// event, sometimes they come in as 2 separate ones, either way
// should be done in 2 secs.
take(2),
takeUntil(timer(1000))
takeUntil(timer(safetyTimeout))
);
expect(isObservable(watcher$)).toBe(true);
// after the initial event, update one of the items
const firstWatcherEvent$ = watcher$.pipe(take(1));
firstWatcherEvent$.subscribe(async () => {
const docId = buildContentId(firstDoc);
const lookup = await getCachedContent(docId);
expect(lookup.hid).toEqual(firstDoc.hid);
expect(lookup._id).toEqual(docId);
lookup.foo = "abc";
const update = await cacheContent(lookup);
expect(update.updated).toEqual(true);
expect(update.id).toEqual(lookup._id);
expect(update.id).toEqual(docId);
});
// spy on output of observable, wait for it to end
const spy = new ObserverSpy();
watcher$.subscribe(spy);
await spy.onComplete();
// wait for the first event (take(n) as a promise for easier testing)
await untilNthEmission(watcher$, 1, safetyTimeout);
// then update the first doc, need to delay longer than
// the debounce on the watch operator or the aggregation will
// think it's all part of the same update
const docId = buildContentId(firstDoc);
const lookup = await getCachedContent(docId);
expect(lookup.hid).toEqual(firstDoc.hid);
expect(lookup._id).toEqual(docId);
lookup.foo = "abc";
const update = await cacheContent(lookup);
expect(update.updated).toEqual(true);
expect(update.id).toEqual(lookup._id);
expect(update.id).toEqual(docId);
// Should see 2 sets of content, the last of which should have the
// foo field in one of its entries
await spy.onComplete();
expect(spy.receivedNext()).toBe(true);
expect(spy.receivedComplete()).toBe(true);
expect(spy.getValuesLength()).toBe(2);
const { contents: lastContents } = spy.getLastValue();
expect(lastContents.some((doc) => doc.foo == "abc")).toBe(true);
@@ -110,17 +118,20 @@ describe("watchHistoryContents", () => {
describe("should respect filter inputs", () => {
const hid$ = of(firstDoc.hid);
const fakeHistory = { id: firstDoc.history_id };
const history = { id: firstDoc.history_id };
let filters;
beforeEach(() => {
filters = new SearchParams();
});
test("deleted flag", async () => {
const filters = new SearchParams();
filters.showDeleted = true;
const input$ = of([fakeHistory, filters]);
const watcher$ = input$.pipe(
const watcher$ = hid$.pipe(
watchHistoryContents({
hid$,
history,
filters,
pageSize: 5,
}),
take(1),
@@ -142,14 +153,12 @@ describe("watchHistoryContents", () => {
});
test("hidden flag", async () => {
const filters = new SearchParams();
filters.showHidden = true;
const input$ = of([fakeHistory, filters]);
const watcher$ = input$.pipe(
const watcher$ = hid$.pipe(
watchHistoryContents({
hid$,
history,
filters,
pageSize: 5,
}),
take(1),
@@ -171,15 +180,13 @@ describe("watchHistoryContents", () => {
});
test("deleted and hidden flag", async () => {
const filters = new SearchParams();
filters.showDeleted = true;
filters.showHidden = true;
const input$ = of([fakeHistory, filters]);
const watcher$ = input$.pipe(
const watcher$ = hid$.pipe(
watchHistoryContents({
hid$,
history,
filters,
pageSize: 5,
}),
take(1),
@@ -1,158 +0,0 @@
/**
* Functions for aggregating incoming cache updates for presentation to the
* scroller. As cache changes roll in, the updates are summarized and sorted in
* a SkipList for fast retrieval.
*/
import SkipList from "proper-skip-list";
import { pipe, take, filter } from "iter-tools/es2018";
import { ACTIONS } from "../caching/db/monitorQuery";
// utils for buildContentResult
const firstEntryKey = (entries) => (entries.length ? entries[0][0] : undefined);
const distance = (a, b) => Math.abs(+a - +b);
export const buildContentResult = (cfg = {}) => {
const { keyDirection = "asc", pageSize = 50, getKey } = cfg;
if (keyDirection != "asc" && keyDirection != "desc") {
throw new Error(`buildContentResult: invalid sort direction: ${keyDirection}`);
}
return ([updateMap, targetKey]) => {
if (isNaN(targetKey)) {
console.warn("Target key should be numeric", updateMap, targetKey);
throw new Error("buildContentResult: targetKey must be a numeric value", targetKey);
}
const { matchingValue, desc, asc } = updateMap.findEntries(targetKey);
// iterator transformations to find some up/down from target
const up = pipe(
filter(([k]) => k > targetKey),
take(pageSize)
);
const down = pipe(
filter(([k]) => k < targetKey),
take(pageSize)
);
// make iterators into concrete arrays
const top = Array.from(up(asc));
const bottom = Array.from(down(desc));
// startKey is the closest key in the result set to the requested targetKey
let startKey;
if (matchingValue !== undefined) {
startKey = getKey(matchingValue);
} else {
const topKey = firstEntryKey(top);
const bottomKey = firstEntryKey(bottom);
startKey = findClosestKey(targetKey, topKey, bottomKey);
}
// assemble contents array by combining the results
const match = matchingValue ? [[targetKey, matchingValue]] : [];
const contentEntries = [...top.reverse(), ...match, ...bottom];
const contentList = contentEntries.map(([k, v]) => v);
// reverse order for ascending content
const contents = keyDirection == "desc" ? contentList : contentList.reverse();
return { contents, startKey, targetKey };
};
};
/**
* Finds closest of keys by measuring numeric distance from targetKey
*
* @param {String|Number} targetKey Key to match
* @param {String|Number} keys Keys to check
*
* @return {String|Number} Member of keys closest to targetKey numerially
*/
const findClosestKey = (targetKey, ...keys) => {
return keys
.filter((val) => val !== undefined)
.reduce((result, key) => {
if (result !== undefined) {
const keyCloser = distance(targetKey, key) < distance(targetKey, result);
return keyCloser ? key : result;
}
return key;
}, undefined);
};
/**
* Fresh update map, gets built when inputs change
*/
export const newUpdateMap = () => {
return new SkipList();
};
/**
* Generates a can function for consuming updates from the pouchdb-live-find.
* Update variable will either be a big initial chunk of results matching the
* query or a follow-up incremental change.
* @param {*} cfg
*/
export const processContentUpdate = (cfg = {}) => {
const {
// function to extract a key from the doc.
// this should return a hid or an element_index
getKey = (doc) => doc._id,
} = cfg;
return (resultMap, update) => {
const { initialMatches = [], action, doc } = update;
// initial load
if (action == ACTIONS.INITIAL && initialMatches.length) {
initialMatches.forEach((match) => {
resultMap.upsert(getKey(match), match);
});
}
// incremental updates
if (action && doc) {
const key = getKey(doc);
switch (action) {
case ACTIONS.IGNORE:
console.log("ignored update", doc);
break;
case ACTIONS.UPDATE:
case ACTIONS.ADD:
resultMap.upsert(key, doc);
break;
case ACTIONS.REMOVE:
resultMap.delete(key);
break;
}
}
return resultMap;
};
};
// Configurable function returns a key for the updateMap from a doc during upate operations
// aggregate results in a map for each set of id + params
export const getKeyForUpdateMap = (keyField) => (doc) => {
let rawKey;
if (keyField in doc) {
rawKey = doc[keyField];
} else {
// won't have a full doc on a delete, but it's embeedded in the id
const parts = doc._id.split("-");
rawKey = parseInt(parts[1]);
}
const result = parseInt(rawKey);
if (isNaN(result)) {
console.warn("Non integer key for document", doc);
throw new Error("Key for update map should be an integer");
}
return result;
};
@@ -1,54 +1,842 @@
[
{
"element_identifier": "M117-bl.fq",
"object": {
"contents_url": "/api/dataset_collections/5a1cff6882ddb5b2/contents/fb85969571388350",
"collection_type": "paired",
"model_class": "DatasetCollection",
"id": "fb85969571388350"
},
"id": "5a9381f02a7b89af",
"model_class": "DatasetCollectionElement",
"id": "068bb135123e39c3",
"element_type": "hda",
"element_index": 0,
"element_type": "dataset_collection",
"model_class": "DatasetCollectionElement"
"element_identifier": "0",
"object": {
"id": "e746d19c9c8cc71c",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"element_identifier": "M117-ch.fq",
"object": {
"contents_url": "/api/dataset_collections/5a1cff6882ddb5b2/contents/ba751ee0539fff04",
"collection_type": "paired",
"model_class": "DatasetCollection",
"id": "ba751ee0539fff04"
},
"id": "8c49be448cfe29bc",
"model_class": "DatasetCollectionElement",
"id": "cc0e2ba29cc1d8af",
"element_type": "hda",
"element_index": 1,
"element_type": "dataset_collection",
"model_class": "DatasetCollectionElement"
"element_identifier": "1",
"object": {
"id": "bd2618511bd6d954",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"element_identifier": "M117C1-bl.fq",
"object": {
"contents_url": "/api/dataset_collections/5a1cff6882ddb5b2/contents/63cd3858d057a6d1",
"collection_type": "paired",
"model_class": "DatasetCollection",
"id": "63cd3858d057a6d1"
},
"id": "b847e822bdc195d0",
"model_class": "DatasetCollectionElement",
"id": "088766ba409c97ae",
"element_type": "hda",
"element_index": 2,
"element_type": "dataset_collection",
"model_class": "DatasetCollectionElement"
"element_identifier": "10",
"object": {
"id": "b1aecce06b45adc8",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"element_identifier": "M117C1-ch.fq",
"object": {
"contents_url": "/api/dataset_collections/5a1cff6882ddb5b2/contents/b472e2eb553fa0d1",
"collection_type": "paired",
"model_class": "DatasetCollection",
"id": "b472e2eb553fa0d1"
},
"id": "13cea0e6d733b865",
"model_class": "DatasetCollectionElement",
"id": "e48823c5da673276",
"element_type": "hda",
"element_index": 3,
"element_type": "dataset_collection",
"model_class": "DatasetCollectionElement"
"element_identifier": "100",
"object": {
"id": "01cda2950d5e63c3",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "cf17043882bf0911",
"element_type": "hda",
"element_index": 4,
"element_identifier": "1000",
"object": {
"id": "5f2de0d744e8c295",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "a088eb6d1a3dbcab",
"element_type": "hda",
"element_index": 5,
"element_identifier": "10000",
"object": {
"id": "43bce5ef002caad3",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "35cddac7ff46d42d",
"element_type": "hda",
"element_index": 6,
"element_identifier": "100000",
"object": {
"id": "5fb23b4e4319a7c5",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "eac6cd9007b1908f",
"element_type": "hda",
"element_index": 7,
"element_identifier": "100001",
"object": {
"id": "80233f5f28a6c72f",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "aa4f9dc4708b54b6",
"element_type": "hda",
"element_index": 8,
"element_identifier": "100002",
"object": {
"id": "1c5156c6a0363f35",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "cbd3da2c4f117e14",
"element_type": "hda",
"element_index": 9,
"element_identifier": "100003",
"object": {
"id": "af091cf96ab275f3",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "68a588a2a9524bc5",
"element_type": "hda",
"element_index": 10,
"element_identifier": "100004",
"object": {
"id": "97b2c68eeabafb3d",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "a58d0afa3c515242",
"element_type": "hda",
"element_index": 11,
"element_identifier": "100005",
"object": {
"id": "33867d668bd83fde",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "f0c8870154edf2d6",
"element_type": "hda",
"element_index": 12,
"element_identifier": "100006",
"object": {
"id": "1914213279532687",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "018c8c5d62a8df97",
"element_type": "hda",
"element_index": 13,
"element_identifier": "100007",
"object": {
"id": "713b988311601359",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "d0c151b99af9360b",
"element_type": "hda",
"element_index": 14,
"element_identifier": "100008",
"object": {
"id": "f38a73020009daba",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "29eac6dcbd14f883",
"element_type": "hda",
"element_index": 15,
"element_identifier": "100009",
"object": {
"id": "622229cf50f04387",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "a158574d58234118",
"element_type": "hda",
"element_index": 16,
"element_identifier": "10001",
"object": {
"id": "b68d17846c7fbc83",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "2b98540a14173828",
"element_type": "hda",
"element_index": 17,
"element_identifier": "100010",
"object": {
"id": "6eeb1155086b617b",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "8e114d54b2d1f419",
"element_type": "hda",
"element_index": 18,
"element_identifier": "100011",
"object": {
"id": "e7746068f68fc864",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "749fa1ed60051f98",
"element_type": "hda",
"element_index": 19,
"element_identifier": "100012",
"object": {
"id": "8051d6590bfd64e9",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "911f2df796ad747c",
"element_type": "hda",
"element_index": 20,
"element_identifier": "100013",
"object": {
"id": "5cba928dd1a05f48",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "faa6c009defd91d0",
"element_type": "hda",
"element_index": 21,
"element_identifier": "100014",
"object": {
"id": "eac6ce67595195ec",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "6129b0ae6f8527f0",
"element_type": "hda",
"element_index": 22,
"element_identifier": "100015",
"object": {
"id": "98d427a4631d635a",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "789438fb3dff9d11",
"element_type": "hda",
"element_index": 23,
"element_identifier": "100016",
"object": {
"id": "ddf8d1e1eb7b0fb7",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "a7d7479d06608eca",
"element_type": "hda",
"element_index": 24,
"element_identifier": "100017",
"object": {
"id": "7083648ea486ebf6",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "440b7cc14c8c4d06",
"element_type": "hda",
"element_index": 25,
"element_identifier": "100018",
"object": {
"id": "aaf0f2ebe11ae45c",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "fb4f28349f5baf2d",
"element_type": "hda",
"element_index": 26,
"element_identifier": "100019",
"object": {
"id": "c78ca882684b7434",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "87fd7db76c544224",
"element_type": "hda",
"element_index": 27,
"element_identifier": "10002",
"object": {
"id": "c9c548cca771bdbc",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "a9431fe40e66ea34",
"element_type": "hda",
"element_index": 28,
"element_identifier": "100020",
"object": {
"id": "2fbde361d6a31e7e",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "43acf2f33287edcb",
"element_type": "hda",
"element_index": 29,
"element_identifier": "100021",
"object": {
"id": "b73abcd044761674",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "9a74eaab42e02827",
"element_type": "hda",
"element_index": 30,
"element_identifier": "100022",
"object": {
"id": "7326e2004b6d8758",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "6d8e4d965b771ea5",
"element_type": "hda",
"element_index": 31,
"element_identifier": "100023",
"object": {
"id": "e16a1758d8c32a91",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "c69237419b0ccecb",
"element_type": "hda",
"element_index": 32,
"element_identifier": "100024",
"object": {
"id": "16d9d7a5407b90bb",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "2d09ebff4dc98b05",
"element_type": "hda",
"element_index": 33,
"element_identifier": "100025",
"object": {
"id": "be6aaa8b33c20354",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "359f5b7c99fbb2a6",
"element_type": "hda",
"element_index": 34,
"element_identifier": "100026",
"object": {
"id": "f0d2dd7ce1a0d1d7",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "890b48f1534ba81c",
"element_type": "hda",
"element_index": 35,
"element_identifier": "100027",
"object": {
"id": "a4243929ecd1e137",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "4220951a437ba60e",
"element_type": "hda",
"element_index": 36,
"element_identifier": "100028",
"object": {
"id": "efdbb5c431ed86c4",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "f306434746a46cb6",
"element_type": "hda",
"element_index": 37,
"element_identifier": "100029",
"object": {
"id": "0c7525f276af0cb2",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "abc747938ed8a600",
"element_type": "hda",
"element_index": 38,
"element_identifier": "10003",
"object": {
"id": "0032a856b26c744e",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "63636a455c5b21fa",
"element_type": "hda",
"element_index": 39,
"element_identifier": "100030",
"object": {
"id": "aa1ea414cbd09e79",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "e8c3a4e75936ee34",
"element_type": "hda",
"element_index": 40,
"element_identifier": "100031",
"object": {
"id": "9a0d644f6e3ebb6f",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "25dce1f99c32a06f",
"element_type": "hda",
"element_index": 41,
"element_identifier": "100032",
"object": {
"id": "c6d7ec9dd85960fd",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "0f308c8bc2dba01e",
"element_type": "hda",
"element_index": 42,
"element_identifier": "100033",
"object": {
"id": "c1e28fb74593e606",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "134707f5254d3467",
"element_type": "hda",
"element_index": 43,
"element_identifier": "100034",
"object": {
"id": "edb9e58b575cfb18",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "af2a47bf25ac31c6",
"element_type": "hda",
"element_index": 44,
"element_identifier": "100035",
"object": {
"id": "595375e544772627",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "9b4142aa13cacd8d",
"element_type": "hda",
"element_index": 45,
"element_identifier": "100036",
"object": {
"id": "d917f9554bc7b276",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "8732705714d1b967",
"element_type": "hda",
"element_index": 46,
"element_identifier": "100037",
"object": {
"id": "17ac36cd25e09d75",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "9faac61c404df8d0",
"element_type": "hda",
"element_index": 47,
"element_identifier": "100038",
"object": {
"id": "fc587326c65e935c",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "eec6329884514299",
"element_type": "hda",
"element_index": 48,
"element_identifier": "100039",
"object": {
"id": "0f1c2717b9554064",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "0a7c50c76808cb45",
"element_type": "hda",
"element_index": 49,
"element_identifier": "10004",
"object": {
"id": "175262dbcbd9987f",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "1ff31d34983529f8",
"element_type": "hda",
"element_index": 50,
"element_identifier": "100040",
"object": {
"id": "7c3fb968df7e50f1",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "0af255ba4961216c",
"element_type": "hda",
"element_index": 51,
"element_identifier": "100041",
"object": {
"id": "cf83f2dcc9f7362b",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "5be07bbe21af2e4a",
"element_type": "hda",
"element_index": 52,
"element_identifier": "100042",
"object": {
"id": "4efb7c25e2bb7fa8",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "8ab1435b8102e81e",
"element_type": "hda",
"element_index": 53,
"element_identifier": "100043",
"object": {
"id": "defeea39a9509c16",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "842e3a8e9b827812",
"element_type": "hda",
"element_index": 54,
"element_identifier": "100044",
"object": {
"id": "5f5b885264688585",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "a5824a57e59503dc",
"element_type": "hda",
"element_index": 55,
"element_identifier": "100045",
"object": {
"id": "041689872363985f",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "616698141b35a61f",
"element_type": "hda",
"element_index": 56,
"element_identifier": "100046",
"object": {
"id": "2839cb63aea76bb4",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "d36010717df75f3d",
"element_type": "hda",
"element_index": 57,
"element_identifier": "100047",
"object": {
"id": "4236bdc00992172f",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "f5d1282ba4e61ea9",
"element_type": "hda",
"element_index": 58,
"element_identifier": "100048",
"object": {
"id": "bf8ded787d0c5b53",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
},
{
"model_class": "DatasetCollectionElement",
"id": "8dc4bd112d29b27d",
"element_type": "hda",
"element_index": 59,
"element_identifier": "100049",
"object": {
"id": "023453c935463dba",
"model_class": "HistoryDatasetAssociation",
"state": "ok",
"hda_ldda": "hda",
"history_id": "e90facaf332c3038"
}
}
]
]
@@ -0,0 +1,43 @@
import { watchForChange } from "jest/helpers";
import { defaultPayload } from "../providers/ContentProvider";
/**
* Vue adds a bunch of setters and getters so we need to create our own comparison fn
*/
export const sameVueObj = (a, b) => {
return JSON.stringify(a) == JSON.stringify(b);
};
/**
* Detects a new emission of a "payload" a standardized object delivered by HistoryContentProvider
* and CollectionContentProvider, skips the default value that's emitted when it initialized
*
* @param {Object} config vm, propName, label, other watchForChange configs
* @return {Promise} promise that resolves with the changed payload
*/
export const payloadChange = async (config = {}) => {
const { vm, propName = "payload", label = "payload change", skipDefault = false, ...cfg } = config;
const result = await watchForChange({ vm, propName, label, ...cfg });
// can see how fast the response comes back by monitoring this result
// console.log("payloadChange result", JSON.stringify(result.newVal));
// chopout the placeholder flag for the default
// eslint-disable-next-line no-unused-vars
const { placeholder, ...payload } = result.newVal;
if (skipDefault && sameVueObj(payload, defaultPayload)) {
return await payloadChange(config);
}
return payload;
};
export const reportPayload = (payload, { indexKey = "hid", label = "payload" } = {}) => {
const { contents = [], startKeyIndex, ...resultFields } = payload;
const keys = contents.map((o, idx) => {
const thisKey = o[indexKey];
const keyString = idx == startKeyIndex ? `${thisKey}*` : thisKey;
return keyString;
});
const report = { keys, startKeyIndex, ...resultFields };
console.log(label, report);
};
@@ -0,0 +1,25 @@
// Doctored collection & children for unit tests
import rawCollection from "./json/DatasetCollection";
import rawNestedCollection from "./json/DatasetCollection.nested.json";
import rawCollectionContent from "./json/collectionContent.json";
import rawNestedCollectionContent from "./json/collectionContent.nested.json";
// Sample collection for CCP tests
const parent_url = "/foo";
export const testCollectionContent = rawCollectionContent.map((props) => ({ ...props, parent_url }));
export const testCollection = Object.assign({}, rawCollection, {
contents_url: parent_url,
element_count: testCollectionContent.length,
});
export const testNestedCollection = Object.assign({}, rawNestedCollection, {
parent_url: parent_url,
contents_url: "/foo/bar",
element_count: rawNestedCollectionContent.length,
});
// a list of element_indexes handy for testing
export const indices = (list) => list.map(({ element_index, _id, parent_url }) => ({ element_index, _id, parent_url }));
@@ -0,0 +1,24 @@
import { History, SearchParams } from "../model";
import rawHistory from "./json/History.json";
import rawHistoryContent from "./json/historyContent.json";
// doctor the sample content
export const testHistoryContent = rawHistoryContent.map((item) => {
item.history_id = rawHistory.id;
return item;
});
// doctor the sample history
export const testHistory = new History({
...rawHistory,
hid_counter: testHistoryContent[0].hid + 1,
});
// simplified filter simlates server-side complete set
export const serverContent = (filters = new SearchParams()) => {
return testHistoryContent.filter((o) => {
if (!filters.showDeleted && o.deleted) return false;
if (!filters.showHidden && !o.visible) return false;
return true;
});
};
@@ -1,9 +1,7 @@
import { of, race, pipe, concat, iif } from "rxjs";
import { filter, mergeMap, switchMap, delay, share, repeat, take, takeWhile } from "rxjs/operators";
import { cacheContent, monitorContentQuery } from "components/History/caching";
import { singleUpdateResult } from "components/History/caching/db/monitorQuery";
import { filter, map, mergeMap, switchMap, delay, share, repeat, take, takeWhile } from "rxjs/operators";
import { cacheContent, monitorContentQuery, loadHistoryContents } from "components/History/caching";
import { fetchDatasetById, fetchDatasetCollectionById, fetchInvocationStepById } from "./fetch";
import { loadHistoryContents } from "components/History/caching";
// prettier-ignore
export const datasetMonitor = (cfg = {}) => {
@@ -49,18 +47,21 @@ const buildPouchRequest = (id, contentType) => {
const createMonitor = (id, contentType, fetcher, spinUpDelay = 250) => {
// build the pouchdb/mongo request, which is a selector
// and limits, offsets, etc
const request = buildPouchRequest(id, contentType);
// retrieve changes from cache
const changes$ = of(buildPouchRequest(id, contentType)).pipe(
const changes$ = of(request).pipe(
monitorContentQuery(),
singleUpdateResult(),
// singleUpdateResult(),
filter(change => change.match && change.doc),
map(change => change.doc),
share()
);
// cache results that reflect non-deleted existing data in the cache
const existing$ = changes$.pipe(
filter(Boolean),
);
);
// load and cache dataset from server then switch over to the monitor
const fetchItem$ = of(id).pipe(
@@ -1,6 +1,6 @@
import { of } from "rxjs";
import { ObserverSpy } from "@hirez_io/observer-spy";
import * as caching from "components/History/caching/loadHistoryContents";
import { loadHistoryContents } from "../../History/caching";
import { monitorHistoryUntilTrue, invocationStepMonitor } from "./monitors";
import * as fetch from "./fetch";
@@ -58,7 +58,7 @@ describe("monitorHistoryUntilTrue", () => {
stopMonitor = false;
completed = false;
mockLoadHistoryContent = of(1, 2, 3);
jest.spyOn(caching, 'loadHistoryContents').mockImplementation(() => () => mockLoadHistoryContent);
loadHistoryContents.mockImplementation(() => () => mockLoadHistoryContent);
monitor$ = monitorHistoryUntilTrue(stopFn, historyId, 20);
});
+7 -6
View File
@@ -4,11 +4,12 @@
*/
import { merge } from "rxjs";
import { mapTo, delay, share } from "rxjs/operators";
import { mapTo, debounceTime, distinctUntilChanged, publish } from "rxjs/operators";
export const activity = (cfg = {}) => (source) => {
const { period = 500 } = cfg;
const on = source.pipe(mapTo(true), share());
const off = on.pipe(delay(period), mapTo(false));
return merge(on, off);
export const activity = (period = 500) => {
return publish((src) => {
const on = src.pipe(mapTo(true));
const off = src.pipe(debounceTime(period), mapTo(false));
return merge(on, off).pipe(distinctUntilChanged());
});
};
+8
View File
@@ -0,0 +1,8 @@
import { publish, buffer, debounceTime } from "rxjs/operators";
export const burst = (period = 100) => {
return publish((src) => {
const flush = src.pipe(debounceTime(period));
return src.pipe(buffer(flush));
});
};
+50
View File
@@ -0,0 +1,50 @@
import { map } from "rxjs/operators";
/**
* Bust incoming numeric source values into blocks of designated size.
*
* @param {*} chunkSize
* @param {*} ceil
*/
// prettier-ignore
export const chunk = (chunkSize, ceil = false) => {
return map((chunkMe) => {
const rawVal = chunkMe / chunkSize;
return chunkSize * (ceil ? Math.ceil(rawVal) : Math.floor(rawVal));
})
};
/**
* Change one parameter to be an multiple of indicated block size. Used to
* regulate the URLs we send to the server so that some will be cached. Pos is
* the position in the combined inputs array, and the chunkSize is the size of
* the block to break the value into.
*
* Ex: Source Inputs [ x, y, z, 750 ],
* chunkParam(3, 200)
* Results in [ x, y, z, 600]
*
* @param {integer} pos Input array parameter number to chunk
* @param {integer} chunkSize Size of chunks
* @param {Boolean} ceil Math.ceil or Math.floor chunk value
*/
// prettier-ignore
export const chunkParam = (pos, chunkSize, ceil = false) => {
return map((inputs) => {
const chunkMe = inputs[pos];
const rawVal = 1.0 * chunkMe / chunkSize;
const chunkedVal = chunkSize * (ceil ? Math.ceil(rawVal) : Math.floor(rawVal));
const newInputs = inputs.slice();
newInputs[pos] = chunkedVal;
return newInputs;
})
}
export const chunkProp = (propName, chunkSize, ceil = false) => {
return map((obj) => {
const chunkMe = obj[propName];
const rawVal = (1.0 * chunkMe) / chunkSize;
const chunkedVal = chunkSize * (ceil ? Math.ceil(rawVal) : Math.floor(rawVal));
return { ...obj, [propName]: chunkedVal };
});
};
+16
View File
@@ -0,0 +1,16 @@
import { pipe } from "rxjs";
import { map } from "rxjs/operators";
/**
* passing objects into the worker removes class information, Pass in an array
* of constructors to be assigned, in order of the input array.
*/
// prettier-ignore
export const hydrate = (constructors = []) => pipe(
map((inputs) => {
return inputs.map((rawInput, i) => {
const C = constructors[i];
return C ? new C(rawInput) : rawInput;
});
})
)
+21
View File
@@ -0,0 +1,21 @@
export { activity } from "./activity";
export { burst as debounceBurst } from "./burst";
export { chunk, chunkParam, chunkProp } from "./chunk";
export { decay } from "./decay";
export { firstValueFrom } from "./firstValueFrom";
export { hydrate } from "./hydrate";
export { monitorBackboneModel } from "./monitorBackboneModel";
export { monitorXHR } from "./monitorXHR";
export { needs } from "./needs";
export { nth } from "./nth";
export { shareButDie } from "./shareButDie";
export { show } from "./show";
export { singleton } from "./singleton";
export { throttleDistinct } from "./throttleDistinct";
export { toggle } from "./toggle";
export { waitForInit } from "./waitForInit";
export { whenAny } from "./whenAny";
export { watchVuexSelector } from "./vuex";
// Do not export rxjsDebugging in the barrel file because it has global consequences
// export { initSpy } from "./rxjsDebugging";
+12
View File
@@ -0,0 +1,12 @@
// I'm actually unclear on why I needed this operator to make pouchdb
// observables work. This bears further investigation. To my knowledge
// withLatestFrom() does the same thing as this operator, yet this works and
// withLatestFrom() doesn't when used with pouchdb observable creation.
import { zip, of, pipe } from "rxjs";
import { concatMap } from "rxjs/operators";
// prettier-ignore
export const needs = (dep$) => pipe(
concatMap((src) => zip(of(src), dep$))
);
+13
View File
@@ -0,0 +1,13 @@
/**
* A conditional tap used for debugging so we don't have to constantly add and remove tap statements
* while working on rxjs streams
*/
import { tap } from "rxjs/operators";
export const show = (flag = false, fn) => {
return tap((val) => {
if (flag) {
fn(val);
}
});
};
+44 -3
View File
@@ -10,17 +10,22 @@ import { eventHubPlugin } from "components/plugins/eventHub";
import BootstrapVue from "bootstrap-vue";
import Vuex from "vuex";
const defaultComparator = (a, b) => a == b;
// Creates a watcher on the indicated vm/prop for use in testing
export function watchForChange({ vm, opts, propName, timeout = 1000, label = "" }) {
export function watchForChange(cfg = {}) {
const { vm, opts, propName, comparator = defaultComparator, timeout = 1000 } = cfg;
const start = new Date();
return new Promise((resolve, reject) => {
const timeoutID = setTimeout(() => {
reject(`${propName} never changed ${label}`);
reject(`${propName} never changed`);
}, timeout);
vm.$watch(
propName,
function (newVal, oldVal) {
if (newVal !== oldVal) {
if (!comparator(newVal, oldVal)) {
clearTimeout(timeoutID);
const stop = new Date();
resolve({ start, stop, propName, elapsed: stop - start, newVal, oldVal });
@@ -31,6 +36,25 @@ export function watchForChange({ vm, opts, propName, timeout = 1000, label = ""
});
}
export function watchUntil(vm, isComplete, timeout = 1000) {
const start = new Date();
return new Promise((resolve, reject) => {
const timeoutID = setTimeout(() => {
if (unwatch) unwatch();
reject("watch timed out");
}, timeout);
const unwatch = vm.$watch(isComplete, (isDone) => {
if (!isDone) return;
clearTimeout(timeoutID);
const stop = new Date();
if (unwatch) unwatch();
resolve({ start, stop, elapsed: stop - start });
});
});
}
// formats a console.log to more easily read the passed object
export const show = (obj) => console.log(JSON.stringify(obj, null, 4));
@@ -105,3 +129,20 @@ export function waitForLifecyleEvent(vm, lifecycleHookName, cfg = {}) {
)
.toPromise();
}
// Waits for an observable to emit N times then resolves a promise
// returns a promise because it's easier to test that way in async jest tests
export const untilNthEmission = (src$, n = 1, safetyTimeout = 2000) => {
return new Promise((resolve, reject) => {
let result;
// prettier-ignore
src$.pipe(
take(n),
takeUntil(timer(safetyTimeout))
).subscribe({
next: (val) => (result = val),
complete: () => resolve(result),
error: reject,
});
});
};