UNPKG

ghost

Version:

The professional publishing platform

481 lines (411 loc) 17.1 kB
const _ = require('lodash'); const debug = require('@tryghost/debug')('services:url:resources'); const DomainEvents = require('@tryghost/domain-events'); const {URLResourceUpdatedEvent} = require('../../../shared/events'); const Resource = require('./Resource'); const config = require('../../../shared/config'); const models = require('../../models'); // This listens to all manner of model events to find new content that needs a URL... const events = require('../../lib/common/events'); /** @typedef {'posts' | 'pages' | 'tags' | 'authors'} ResourceType */ /** * @description At the moment the resources class is directly responsible for data population * for URLs...but because it's actually a storage cache of all published * resources in the system, could also be used as a cache for Content API in * the future. * * Each entry in the database will be represented by a "Resource" (see /Resource.js). */ class Resources { /** * * @param {Object} options * @param {Object} [options.resources] - resources to initialize with instead of fetching them from the database * @param {Object} [options.queue] - instance of the Queue class * @param {Object[]} [options.resourcesConfig] - resource config used when handling resource events and fetching */ constructor({resources = {}, queue, resourcesConfig = []} = {}) { this.queue = queue; this.resourcesConfig = resourcesConfig; this.data = resources; this.listeners = []; } /** * @description Little helper to register on Ghost events and remember the listener functions to be able * to unsubscribe. * * @param {String} eventName * @param {Function} listener * @private */ _listenOn(eventName, listener) { this.listeners.push({ eventName: eventName, listener: listener }); events.on(eventName, listener); } /** * @description Helper function to initialize data fetching. */ async fetchResources() { const ops = []; debug('fetchResources'); // NOTE: Iterate over all resource types (posts, users etc..) and call `_fetch`. _.each(this.resourcesConfig, (resourceConfig) => { this.data[resourceConfig.type] = []; // NOTE: We are querying knex directly, because the Bookshelf ORM overhead is too slow. ops.push(this._fetch(resourceConfig)); }); await Promise.all(ops); } /** * @description Each resource type needs to register resource/model events to get notified * about updates/deletions/inserts. * * For example for a "tag" resource type with following configuration: * events: { * add: 'tag.added', * update: ['tag.edited', 'tag.attached', 'tag.detached'], * remove: 'tag.deleted' * } * there would be: * 1 event listener connected to "_onResourceAdded" handler and it's 'tag.added' event * 3 event listeners connected to "_onResourceUpdated" handler and it's 'tag.edited', 'tag.attached', 'tag.detached' events * 1 event listener connected to "_onResourceRemoved" handler and it's 'tag.deleted' event */ initEventListeners() { _.each(this.resourcesConfig, (resourceConfig) => { this.data[resourceConfig.type] = []; this._listenOn(resourceConfig.events.add, async (model) => { await this._onResourceAdded(resourceConfig.type, model); }); if (_.isArray(resourceConfig.events.update)) { resourceConfig.events.update.forEach((event) => { this._listenOn(event, async (model) => { await this._onResourceUpdated(resourceConfig.type, model); }); }); } else { this._listenOn(resourceConfig.events.update, async (model) => { await this._onResourceUpdated(resourceConfig.type, model); }); } this._listenOn(resourceConfig.events.remove, (model) => { this._onResourceRemoved(resourceConfig.type, model); }); }); } /** * @description The actual call to the model layer, which will execute raw knex queries to ensure performance. * @param {Object} resourceConfig * @param {Object} options * @returns {Promise} * @private */ async _fetch(resourceConfig, options = {offset: 0, limit: 999}) { debug('_fetch', resourceConfig.type, resourceConfig.modelOptions); let modelOptions = _.cloneDeep(resourceConfig.modelOptions); const isSQLite = config.get('database:client') === 'sqlite3'; // CASE: prevent "too many SQL variables" error on SQLite3 (https://github.com/TryGhost/Ghost/issues/5810) if (isSQLite) { modelOptions.offset = options.offset; modelOptions.limit = options.limit; } const now = Date.now(); const objects = await models.Base.Model.raw_knex.fetchAll(modelOptions); debug('_fetch.fetched', resourceConfig.type, objects.length, `${Date.now() - now}ms`); for (const object of objects) { this.data[resourceConfig.type].push(new Resource(resourceConfig.type, object)); } if (objects.length && isSQLite) { options.offset = options.offset + options.limit; return this._fetch(resourceConfig, {offset: options.offset, limit: options.limit}); } return objects; } /** * @description Call the model layer to fetch a single resource via raw knex queries. * * This function was invented, because the model event is a generic event, which is independent of any * api version behavior. We have to ensure that a model matches the conditions of the configured api version * in the theme. * * See https://github.com/TryGhost/Ghost/issues/10124. * * @param {Object} resourceConfig * @param {String} id * @returns {Promise} * @private */ _fetchSingle(resourceConfig, id) { let modelOptions = _.cloneDeep(resourceConfig.modelOptions); modelOptions.id = id; return models.Base.Model.raw_knex.fetchAll(modelOptions); } /** * @description Helper function to prepare the received model's relations. * * This helper was added to reduce the number of fields we keep in cache for relations. * * If we resolve (https://github.com/TryGhost/Ghost/issues/10360) and talk to the Content API, * we could pass on e.g. `?include=authors&fields=authors.id,authors.slug`, but the API has to support it. * * @param {import('bookshelf').Model} model * @param {Object} resourceConfig * @private */ _prepareModelSync(model, resourceConfig) { const exclude = resourceConfig.modelOptions.exclude; const withRelatedFields = resourceConfig.modelOptions.withRelatedFields; const obj = _.omit(model.toJSON(), exclude); if (withRelatedFields) { _.each(withRelatedFields, (fields, key) => { if (!obj[key]) { return; } obj[key] = _.map(obj[key], (relation) => { const relationToReturn = {}; _.each(fields, (field) => { const fieldSanitized = field.replace(/^\w+./, ''); relationToReturn[fieldSanitized] = relation[fieldSanitized]; }); return relationToReturn; }); }); const withRelatedPrimary = resourceConfig.modelOptions.withRelatedPrimary; if (withRelatedPrimary) { _.each(withRelatedPrimary, (relation, primaryKey) => { if (!obj[primaryKey] || !obj[relation]) { return; } const targetTagKeys = Object.keys(obj[relation].find((item) => { return item.id === obj[primaryKey].id; })); obj[primaryKey] = _.pick(obj[primaryKey], targetTagKeys); }); } } return obj; } /** * @description Listener for "model added" event. * * If we receive an event from the model layer, we push the new resource into the queue. * The subscribers (the url generators) have registered for this event and the queue will call * all subscribers sequentially. The first generator, where the conditions match the resource, will * own the resource and it's url. * * @param {ResourceType} type * @param {import('bookshelf').Model} model * @private */ async _onResourceAdded(type, model) { debug('_onResourceAdded', type); const resourceConfig = _.find(this.resourcesConfig, {type: type}); // NOTE: synchronous handling for post and pages so that their URL is available without a delay // for more context and future improvements check https://github.com/TryGhost/Ghost/issues/10360 if (['posts', 'pages'].includes(type)) { const obj = this._prepareModelSync(model, resourceConfig); const resource = new Resource(type, obj); debug('_onResourceAdded', type); this.data[type].push(resource); this.queue.start({ event: 'added', action: 'added:' + model.id, eventData: { id: model.id, type: type } }); } else { const [dbResource] = await this._fetchSingle(resourceConfig, model.id); if (dbResource) { const resource = new Resource(type, dbResource); debug('_onResourceAdded', type); this.data[type].push(resource); this.queue.start({ event: 'added', action: 'added:' + model.id, eventData: { id: model.id, type: type } }); } } } /** * * @param {Object} model resource model * @returns */ _containsRoutingAffectingChanges(model, ignoredProperties) { if (model._changed && Object.keys(model._changed).length) { return _.difference(Object.keys(model._changed), ignoredProperties).length !== 0; } // NOTE: returning true here as "_changed" property might not be available on attached/detached events // assuming there were route affecting changes by default return true; } /** * @description Listener for "model updated" event. * * CASE: * - post was fetched on bootstrap * - that means, the post is already published * - resource exists, but nobody owns it * - if the model changes, it can be that somebody will then own the post * * CASE: * - post was fetched on bootstrap * - that means, the post is already published * - resource exists and is owned by somebody * - but the data changed and is maybe no longer owned? * - e.g. featured:false changes and your filter requires featured posts * * @param {ResourceType} type * @param {import('bookshelf').Model} model * @private */ async _onResourceUpdated(type, model) { debug('_onResourceUpdated', type); const resourceConfig = _.find(this.resourcesConfig, {type: type}); // NOTE: check if any of the route-related fields were changed and only proceed if so const ignoredProperties = [...resourceConfig.modelOptions.exclude, 'updated_at']; if (!this._containsRoutingAffectingChanges(model, ignoredProperties)) { const cachedResource = this.getByIdAndType(type, model.id); if (cachedResource && model._changed && Object.keys(model._changed).includes('updated_at')) { DomainEvents.dispatch(URLResourceUpdatedEvent.create(Object.assign(cachedResource.data, { resourceType: cachedResource.config.type, updated_at: model._changed.updated_at }))); } debug('skipping _onResourceUpdated because only non-route-related properties changed'); return false; } // NOTE: synchronous handling for post and pages so that their URL is available without a delay // for more context and future improvements check https://github.com/TryGhost/Ghost/issues/10360 if (['posts', 'pages'].includes(type)) { // CASE: search for the target resource in the cache this.data[type].every((resource) => { if (resource.data.id === model.id) { const obj = this._prepareModelSync(model, resourceConfig); resource.update(obj); // CASE: Resource is not owned, try to add it again (data has changed, it could be that somebody will own it now) if (!resource.isReserved()) { this.queue.start({ event: 'added', action: 'added:' + model.id, eventData: { id: model.id, type: type } }); } // break! return false; } return true; }); } else { const [dbResource] = await this._fetchSingle(resourceConfig, model.id); const resource = this.data[type].find(r => (r.data.id === model.id)); // CASE: cached resource exists, API conditions matched with the data in the db if (resource && dbResource) { resource.update(dbResource); // CASE: pretend it was added if (!resource.isReserved()) { this.queue.start({ event: 'added', action: 'added:' + dbResource.id, eventData: { id: dbResource.id, type: type } }); } } else if (!resource && dbResource) { await this._onResourceAdded(type, model); } else if (resource && !dbResource) { await this._onResourceRemoved(type, model); } } } /** * @description Listener for "model removed" event. * @param {ResourceType} type * @param {import('bookshelf').Model} model * @private */ _onResourceRemoved(type, model) { debug('_onResourceRemoved', type); const resourceId = model._previousAttributes.id; const index = this.data[type].findIndex(resource => resource.data.id === resourceId); // Resource might not be in cache if e.g. visibility was internal if (index === -1) { debug('Resource not found in cache', resourceId); return; } // remove the resource from cache const [removedResource] = this.data[type].splice(index, 1); removedResource.remove(); } /** * @description Get all cached resources. * @returns {Object} */ getAll() { return this.data; } /** * @description Get all cached resourced by type. * @param {ResourceType} type * @returns {Object} */ getAllByType(type) { return this.data[type]; } /** * @description Get all cached resourced by resource id and type. * @param {ResourceType} type * @param {string} id * @returns {Object} */ getByIdAndType(type, id) { return this.data[type]?.find(r => r.data.id === id); } /** * @description Reset this class instance. * * Is triggered if you switch API versions. */ reset() { _.each(this.listeners, (obj) => { events.removeListener(obj.eventName, obj.listener); }); this.listeners = []; this.data = {}; } /** * @description Soft reset this class instance. Only used for test env. * It will only clear the cache. */ softReset() { this.data = {}; _.each(this.resourcesConfig, (resourceConfig) => { this.data[resourceConfig.type] = []; }); } /** * @description Release all resources. Get's called during "reset". */ releaseAll() { _.each(this.data, (resources, type) => { _.each(this.data[type], (resource) => { resource.release(); }); }); } } module.exports = Resources;