Ghost/ghost/admin/app/services/local-revisions.js
Chris Raible 046b06fe72
Refactored local revisions to avoid QuotaExceededErrors (#21128)
ref https://ghost-foundation.sentry.io/issues/5908152800/

- In the current state, we are maintaining an 'index' key for all
revisions in localStorage. This gives us quick and easy access to all
the revisions in localStorage, but it requires additional "bookkeeping"
to update the index each time we add/remove a key.
- In some obscure edge cases, this results in the `remove()` method
throwing a `QuotaExceededError` (since removing a revision also requires
updating the index with `localStorage.setItem()`). If the `remove()`
call fails, we are sort of stuck — the only way to reduce our storage
usage is to remove items, but if the `remove()` method throws errors, we
can't do that.
- This change removes the whole index concept, and instead loops over
all the keys in localStorage, filtering by the prefix to find all our
revisions. This makes the `keys()` method slightly more complex, as it
has to filter out keys in localStorage that aren't related to revisions,
but it simplifies saving and removing revisions.
- Critically, this also means that `remove()` should never throw a
`QuotaExceededError`, since it no longer needs to call
`localStorage.setItem()` — it now simply calls
`localStorage.removeItem()` for the revision, which should never fail.
2024-09-26 12:50:31 -07:00

280 lines
9.5 KiB
JavaScript

import * as Sentry from '@sentry/ember';
import Service, {inject as service} from '@ember/service';
import config from 'ghost-admin/config/environment';
import {task, timeout} from 'ember-concurrency';
/**
* Service to manage local post revisions in localStorage
*/
export default class LocalRevisionsService extends Service {
constructor() {
super(...arguments);
if (this.isTesting === undefined) {
this.isTesting = config.environment === 'test';
}
this.MIN_REVISION_TIME = this.isTesting ? 50 : 60000; // 1 minute in ms
this.performSave = this.performSave.bind(this);
this.storage = window.localStorage;
}
@service store;
// base key prefix to avoid collisions in localStorage
_prefix = 'post-revision';
latestRevisionTime = null;
/**
*
* @param {object} data - serialized post data, must include id and revisionTimestamp
* @returns {string} - key to store the revision in localStorage
*/
generateKey(data) {
return `${this._prefix}-${data.id}-${data.revisionTimestamp}`;
}
/**
* Performs the save operations, either immediately or after a delay
*
* leepLatest ensures the latest changes will be saved
* @param {string} type - post or page
* @param {object} data - serialized post data
*/
@task({keepLatest: true})
*saveTask(type, data) {
try {
const currentTime = Date.now();
if (!this.lastRevisionTime || currentTime - this.lastRevisionTime > this.MIN_REVISION_TIME) {
yield this.performSave(type, data);
this.lastRevisionTime = currentTime;
} else {
const waitTime = this.MIN_REVISION_TIME - (currentTime - this.lastRevisionTime);
yield timeout(waitTime);
yield this.performSave(type, data);
this.lastRevisionTime = Date.now();
}
} catch (err) {
Sentry.captureException(err, {tags: {localRevisions: 'saveTaskError'}});
}
}
/**
* Saves the revision to localStorage
*
* If localStorage is full, the oldest revision will be removed
* @param {string} type - post or page
* @param {object} data - serialized post data
* @returns {string | undefined} - key of the saved revision or undefined if it couldn't be saved
*/
performSave(type, data) {
data.id = data.id || 'draft';
data.type = type;
data.revisionTimestamp = Date.now();
const key = this.generateKey(data);
try {
this.storage.setItem(key, JSON.stringify(data));
// Apply the filter after saving
this.filterRevisions(data.id);
return key;
} catch (err) {
if (err.name === 'QuotaExceededError') {
// Remove the current key in case it's already in the index
this.remove(key);
// If there are any revisions, remove the oldest one and try to save again
if (this.keys().length) {
Sentry.captureMessage('LocalStorage quota exceeded. Removing old revisions.', {tags: {localRevisions: 'quotaExceeded'}});
this.removeOldest();
return this.performSave(type, data);
}
// LocalStorage is full and there are no revisions to remove
// We can't save the revision
Sentry.captureMessage('LocalStorage quota exceeded. Unable to save revision.', {tags: {localRevisions: 'quotaExceededNoSpace'}});
return;
} else {
Sentry.captureException(err, {tags: {localRevisions: 'saveError'}});
return;
}
}
}
/**
* Method to trigger the save task
* @param {string} type - post or page
* @param {object} data - serialized post data
*/
scheduleSave(type, data) {
if (data && data.status && data.status === 'draft') {
this.saveTask.perform(type, data);
}
}
/**
* Returns the specified revision from localStorage, or null if it doesn't exist
* @param {string} key - key of the revision to find
* @returns {string | null}
*/
find(key) {
return JSON.parse(this.storage.getItem(key));
}
/**
* Returns all revisions from localStorage as an array, optionally filtered by key prefix and ordered by timestamp
* @param {string | undefined} prefix - optional prefix to filter revision keys
* @returns {Array} - all revisions matching the prefix, ordered by timestamp (newest first)
*/
findAll(prefix = this._prefix) {
const keys = this.keys(prefix);
const revisions = keys.map((key) => {
const revision = JSON.parse(this.storage.getItem(key));
return {
key,
...revision
};
});
// Sort revisions by timestamp, newest first
revisions.sort((a, b) => b.revisionTimestamp - a.revisionTimestamp);
return revisions;
}
/**
* Removes the specified key from localStorage
* @param {string} key
*/
remove(key) {
this.storage.removeItem(key);
}
/**
* Finds the oldest revision and removes it from localStorage to clear up space
*/
removeOldest() {
const keys = this.keys();
const keysByTimestamp = keys.map(key => ({key, timestamp: this.find(key).revisionTimestamp}));
keysByTimestamp.sort((a, b) => a.timestamp - b.timestamp);
this.remove(keysByTimestamp[0].key);
}
/**
* Removes all revisions from localStorage
*/
clear() {
const keys = this.keys();
for (const key of keys) {
this.remove(key);
}
}
/**
* Returns all revision keys from localStorage, optionally filtered by key prefix
* @param {string | undefined} prefix
* @returns {string[]}
*/
keys(prefix = undefined) {
const allKeys = [];
const filterPrefix = prefix || this._prefix;
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key.startsWith(filterPrefix)) {
allKeys.push(key);
}
}
return allKeys;
}
/**
* Logs all revisions to the console
*
* Currently this is the only UI for local revisions
*/
list() {
const revisions = this.findAll();
const data = {};
for (const [key, revision] of Object.entries(revisions)) {
if (!data[revision.title]) {
data[revision.title] = [];
}
data[revision.title].push({
key,
timestamp: revision.revisionTimestamp,
time: new Date(revision.revisionTimestamp).toLocaleString(),
title: revision.title,
type: revision.type,
id: revision.id
});
}
/* eslint-disable no-console */
console.groupCollapsed('Local revisions');
for (const [title, row] of Object.entries(data)) {
// eslint-disable-next-line no-console
console.groupCollapsed(`${title}`);
for (const item of row.sort((a, b) => b.timestamp - a.timestamp)) {
// eslint-disable-next-line no-console
console.groupCollapsed(`${item.time}`);
console.log('Revision ID: ', item.key);
console.groupEnd();
}
console.groupEnd();
}
console.groupEnd();
/* eslint-enable no-console */
}
/**
* Creates a new post from the specified revision
*
* @param {string} key
* @returns {Promise} - the new post model
*/
async restore(key) {
try {
const revision = this.find(key);
let authors = [];
if (revision.authors) {
for (const author of revision.authors) {
const authorModel = await this.store.queryRecord('user', {id: author.id});
authors.push(authorModel);
}
}
let post = this.store.createRecord('post', {
title: `(Restored) ${revision.title}`,
lexical: revision.lexical,
authors,
type: revision.type,
slug: revision.slug || 'untitled',
status: 'draft',
tags: revision.tags || [],
post_revisions: []
});
await post.save();
const location = window.location;
const url = `${location.origin}${location.pathname}#/editor/${post.get('type')}/${post.id}`;
// eslint-disable-next-line no-console
console.log('Post restored: ', url);
return post;
} catch (err) {
// eslint-disable-next-line no-console
console.warn(err);
}
}
/**
* Filters revisions to keep only the most recent 5 for a given post ID
* @param {string} postId - ID of the post to filter revisions for
*/
filterRevisions(postId) {
if (postId === 'draft') {
return; // Ignore filter for drafts
}
const allRevisions = this.findAll(`${this._prefix}-${postId}`);
if (allRevisions.length > 5) {
const revisionsToRemove = allRevisions.slice(5);
revisionsToRemove.forEach((revision) => {
this.remove(revision.key);
});
}
}
}