graphile-build
Version:
Build a GraphQL schema from plugins
267 lines (260 loc) • 9.81 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.LiveSource = exports.LiveProvider = exports.LiveMonitor = exports.LiveCoordinator = void 0;
exports.makeAsyncIteratorFromMonitor = makeAsyncIteratorFromMonitor;
var _callbackToAsyncIterator = _interopRequireDefault(require("./callbackToAsyncIterator"));
var _lodash = require("lodash");
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
/* eslint-disable flowtype/no-weak-types */
const DEBOUNCE_DURATION = 25;
const MONITOR_THROTTLE_DURATION = Math.max(DEBOUNCE_DURATION + 1, parseInt(process.env.LIVE_THROTTLE || "", 10) || 500);
/*
* Sources are long-lived (i.e. in "watch" mode you just re-use the same one
* over and over) because there is no release for them
*/
class LiveSource {
subscribeCollection(_callback, _collectionIdentifier, _predicate) {
return null;
}
subscribeRecord(_callback, _collectionIdentifier, _recordIdentifier) {
return null;
}
}
/*
* Providers enable a namespace, perform validation, and track the sources used
* by that namespace within one single schema build. The should not directly use
* any long-lived features as they do not have an explicit "release"/"close"
* command when a new schema is built.
*/
exports.LiveSource = LiveSource;
class LiveProvider {
constructor(namespace) {
this.namespace = namespace;
this.sources = [];
}
registerSource(source) {
this.sources.push(source);
}
collectionIdentifierIsValid(_collectionIdentifier) {
return false;
}
recordIdentifierIsValid(_collectionIdentifier, _recordIdentifier) {
return false;
}
}
/*
* During a single execution of GraphQL (specifically a subscription request),
* the LiveMonitor tracks the resources viewed and subscribes to updates in them.
*/
exports.LiveProvider = LiveProvider;
class LiveMonitor {
constructor(providers, extraRootValue) {
this.extraRootValue = extraRootValue;
this.released = false;
this.providers = providers;
this.subscriptionReleasersByCounter = {};
this.changeCallback = null;
this.changeCounter = 0;
this.liveConditionsByCounter = {};
this.handleChange = function () {
/* This function is throttled to ~25ms (see constructor); it's purpose is
* to bundle up all the changes that occur in a small window into the same
* handle change flow, so _reallyHandleChange doesn't get called twice in
* quick succession. _reallyHandleChange is then further throttled with a
* larger window, BUT it triggers on both leading and trailing edge,
* whereas this only triggers on the trailing edge.
*/
if (this._reallyHandleChange) {
this._reallyHandleChange();
}
};
this._reallyHandleChange = function () {
// This function is throttled to MONITOR_THROTTLE_DURATION (see constructor)
if (this.changeCallback) {
// Convince Flow this won't suddenly become null
const cb = this.changeCallback;
const counter = this.changeCounter++;
/*
* In live queries we need to know when the current result set has
* finished being calculated so that we know we've received all the
* liveRecord / liveCollection calls and can release the out of date
* ones. To achieve this, we use a custom `subscribe` function which
* calls `rootValue.release()` once the result set has been calculated.
*/
this.subscriptionReleasersByCounter[String(counter)] = [];
this.liveConditionsByCounter[String(counter)] = [];
const changeRootValue = {
...this.extraRootValue,
counter,
liveCollection: this.liveCollection.bind(this, counter),
liveRecord: this.liveRecord.bind(this, counter),
liveConditions: this.liveConditionsByCounter[String(counter)],
release: () => {
// Despite it's name, this means that the execution has complete, which means we're actually releasing everything *before* this.
this.resetBefore(counter);
}
};
cb(changeRootValue);
} else {
// eslint-disable-next-line no-console
console.warn("Change occurred, but no-one was listening");
}
};
this.onChange = function (callback) {
if (this.released) {
throw new Error("Monitors cannot be reused.");
}
if (this.changeCallback) {
throw new Error("Already monitoring for changes");
}
// Throttle to every 250ms
this.changeCallback = callback;
if (this.handleChange) {
setImmediate(this.handleChange);
}
return () => {
if (this.changeCallback === callback) {
this.changeCallback = null;
}
this.release();
};
};
this.handleChange = (0, _lodash.throttle)(this.handleChange.bind(this), DEBOUNCE_DURATION, {
leading: false,
trailing: true
});
if (!this._reallyHandleChange) {
throw new Error("This is just to make flow happy");
}
this._reallyHandleChange = (0, _lodash.throttle)(this._reallyHandleChange.bind(this), MONITOR_THROTTLE_DURATION - DEBOUNCE_DURATION, {
leading: true,
trailing: true
});
this.onChange = this.onChange.bind(this);
}
resetBefore(currentCounter) {
// Clear out of date subscriptionReleasers
{
const oldCounters = Object.keys(this.subscriptionReleasersByCounter).filter(n => parseInt(n, 10) < currentCounter);
for (const oldCounter of oldCounters) {
for (const releaser of this.subscriptionReleasersByCounter[oldCounter]) {
releaser();
}
delete this.subscriptionReleasersByCounter[oldCounter];
}
}
// Clear out of date liveConditions
{
const oldCounters = Object.keys(this.liveConditionsByCounter).filter(n => parseInt(n, 10) < currentCounter);
for (const oldCounter of oldCounters) {
delete this.liveConditionsByCounter[oldCounter];
}
}
}
release() {
if (this.handleChange) {
// $FlowFixMe: throttled function
this.handleChange.cancel();
}
this.handleChange = null;
if (this._reallyHandleChange) {
// $FlowFixMe: throttled function
this._reallyHandleChange.cancel();
}
this._reallyHandleChange = null;
this.resetBefore(Infinity);
this.providers = {};
this.released = true;
}
liveCollection(counter, namespace, collectionIdentifier, predicate = () => true) {
const handleChange = this.handleChange;
if (this.released || !handleChange) {
return;
}
const provider = this.providers[namespace];
if (!provider || provider.sources.length === 0) return;
if (!provider.collectionIdentifierIsValid(collectionIdentifier)) {
throw new Error(`Invalid collection identifier passed to LiveMonitor[${namespace}]: ${collectionIdentifier}`);
}
for (const source of provider.sources) {
const releaser = source.subscribeCollection(handleChange, collectionIdentifier, predicate);
if (releaser) {
this.subscriptionReleasersByCounter[String(counter)].push(releaser);
}
}
}
liveRecord(counter, namespace, collectionIdentifier, recordIdentifier) {
const handleChange = this.handleChange;
if (this.released || !handleChange) {
return;
}
// TODO: if (recordIdentifier == null) {return}
const provider = this.providers[namespace];
if (!provider || provider.sources.length === 0) return;
if (!provider.collectionIdentifierIsValid(collectionIdentifier)) {
throw new Error(`Invalid collection identifier passed to LiveMonitor[${namespace}]: ${collectionIdentifier}`);
}
if (!provider.recordIdentifierIsValid(collectionIdentifier, recordIdentifier)) {
throw new Error(`Invalid record identifier passed to LiveMonitor[${namespace}]: ${collectionIdentifier}`);
}
for (const source of provider.sources) {
const releaser = source.subscribeRecord(handleChange, collectionIdentifier, recordIdentifier);
if (releaser) {
this.subscriptionReleasersByCounter[String(counter)].push(releaser);
}
}
}
}
/*
* There is one coordinator for each build of the GraphQL schema, it tracks the providers
* and gives a handy `subscribe` method that can be used for live queries (assuming
* that the `resolve` is provided the same as in a Query).
*/
exports.LiveMonitor = LiveMonitor;
class LiveCoordinator {
constructor() {
this.providers = {};
this.subscribe = this.subscribe.bind(this);
}
registerProvider(provider) {
const {
namespace
} = provider;
if (this.providers[namespace]) {
throw new Error(`Namespace ${namespace} already registered with Live`);
}
this.providers[namespace] = provider;
}
registerSource(namespace, source) {
if (!this.providers[namespace]) {
// eslint-disable-next-line no-console
console.warn(`LiveProvider '${namespace}' is not registered, skipping live source.`);
return;
}
this.providers[namespace].registerSource(source);
}
getMonitor(extraRootValue) {
return new LiveMonitor(this.providers, extraRootValue);
}
// Tell Flow that we're okay with overwriting this
subscribe(_parent, _args, _context, _info) {
const monitor = this.getMonitor({
liveAbort: e => {
if (iterator) iterator.throw(e);
}
});
const iterator = makeAsyncIteratorFromMonitor(monitor);
return iterator;
}
}
exports.LiveCoordinator = LiveCoordinator;
function makeAsyncIteratorFromMonitor(monitor) {
return (0, _callbackToAsyncIterator.default)(monitor.onChange, {
onClose: release => {
if (release) release();
}
});
}
//# sourceMappingURL=Live.js.map