feat: IndexedDB based caching for labels, inboxes and teams [CW-50] (#6710)

* feat: allow caching of labels in the account scope

* feat: send cache keys in account json response

* feat: kickstart web worker

* feat: setup basic architecture for workers

* feat: install idb

* feat: add datamanger

* fix: typos

* refactor: rename method

* feat: make init db a manual step

* refactor: separate accountIdFromRoute

* feat: cache enabled API client

* feat: enable caching for inboxes and labels

* feat: enable cache for team

* feat: manage exceptions for team

* feat: add team to data manager

* feat: add a generic listener

* refactor: send only cache keys

* refactor: separate validate method

* feat: add listeners

* feat: add event for revalidate

* feat: add cache keys endpoint

* refactor: fetch cache keys instead of full account data

* fix: key pattern

* feat: don't fetch account for cache_keys

* fix: cache key base class

* refactor: cache keys helper

* feat: add helper

* fix: cache-key update logic

* feat: delete indexeddb on logout

* feat: remove worker.js

* refactor: move data-manager

* refactor: name of file

* feat: add test for DataManager

* refactor: add fake idb to jest setup

* test: cache keys helper

* test: cache keys helper

* test: cache_keys in accounts controller

* refactor: remove cache_keys context

* feat: add policy for cache-keys
This commit is contained in:
Shivam Mishra
2023-03-27 12:16:25 +05:30
committed by GitHub
parent 6000028f64
commit 00ee0478eb
33 changed files with 595 additions and 22 deletions

View File

@@ -1,5 +1,6 @@
class Api::V1::AccountsController < Api::BaseController
include AuthHelper
include CacheKeysHelper
skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception,
only: [:create], raise: false
@@ -30,6 +31,10 @@ class Api::V1::AccountsController < Api::BaseController
end
end
def cache_keys
render json: { cache_keys: get_cache_keys }, status: :ok
end
def show
@latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION)
render 'api/v1/accounts/show', format: :json
@@ -47,6 +52,14 @@ class Api::V1::AccountsController < Api::BaseController
private
def get_cache_keys
{
label: fetch_value_for_key(params[:id], Label.name.underscore),
inbox: fetch_value_for_key(params[:id], Inbox.name.underscore),
team: fetch_value_for_key(params[:id], Team.name.underscore)
}
end
def fetch_account
@account = current_user.accounts.find(params[:id])
@current_account_user = @account.account_users.find_by(user_id: current_user.id)

View File

@@ -0,0 +1,15 @@
module CacheKeysHelper
def get_prefixed_cache_key(account_id, key)
"idb-cache-key-account-#{account_id}-#{key}"
end
def fetch_value_for_key(account_id, key)
prefixed_cache_key = get_prefixed_cache_key(account_id, key)
value_from_cache = Redis::Alfred.get(prefixed_cache_key)
return value_from_cache if value_from_cache.present?
# zero epoch time: 1970-01-01 00:00:00 UTC
'0000000000'
end
end

View File

@@ -13,6 +13,19 @@ class ApiClient {
return `${this.baseUrl()}/${this.resource}`;
}
// eslint-disable-next-line class-methods-use-this
get accountIdFromRoute() {
const isInsideAccountScopedURLs = window.location.pathname.includes(
'/app/accounts'
);
if (isInsideAccountScopedURLs) {
return window.location.pathname.split('/')[3];
}
return '';
}
baseUrl() {
let url = this.apiVersion;
@@ -20,15 +33,8 @@ class ApiClient {
url = `/enterprise${url}`;
}
if (this.options.accountScoped) {
const isInsideAccountScopedURLs = window.location.pathname.includes(
'/app/accounts'
);
if (isInsideAccountScopedURLs) {
const accountId = window.location.pathname.split('/')[3];
url = `${url}/accounts/${accountId}`;
}
if (this.options.accountScoped && this.accountIdFromRoute) {
url = `${url}/accounts/${this.accountIdFromRoute}`;
}
return url;

View File

@@ -0,0 +1,82 @@
/* global axios */
import { DataManager } from '../helper/CacheHelper/DataManager';
import ApiClient from './ApiClient';
class CacheEnabledApiClient extends ApiClient {
constructor(resource, options = {}) {
super(resource, options);
this.dataManager = new DataManager(this.accountIdFromRoute);
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
throw new Error('cacheModelName is not defined');
}
get(cache = false) {
if (cache) {
return this.getFromCache();
}
return axios.get(this.url);
}
// eslint-disable-next-line class-methods-use-this
extractDataFromResponse(response) {
return response.data.payload;
}
// eslint-disable-next-line class-methods-use-this
marshallData(dataToParse) {
return { data: { payload: dataToParse } };
}
async getFromCache() {
await this.dataManager.initDb();
const { data } = await axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
);
const cacheKeyFromApi = data.cache_keys[this.cacheModelName];
const isCacheValid = await this.validateCacheKey(cacheKeyFromApi);
let localData = [];
if (isCacheValid) {
localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
}
if (localData.length === 0) {
return this.refetchAndCommit(cacheKeyFromApi);
}
return this.marshallData(localData);
}
async refetchAndCommit(newKey = null) {
await this.dataManager.initDb();
const response = await axios.get(this.url);
this.dataManager.replace({
modelName: this.cacheModelName,
data: this.extractDataFromResponse(response),
});
await this.dataManager.setCacheKeys({
[this.cacheModelName]: newKey,
});
return response;
}
async validateCacheKey(cacheKeyFromApi) {
if (!this.dataManager.db) {
await this.dataManager.initDb();
}
const cachekey = await this.dataManager.getCacheKey(this.cacheModelName);
return cacheKeyFromApi === cachekey;
}
}
export default CacheEnabledApiClient;

View File

@@ -2,7 +2,11 @@
import Cookies from 'js-cookie';
import endPoints from './endPoints';
import { setAuthCredentials, clearCookiesOnLogout } from '../store/utils/api';
import {
setAuthCredentials,
clearCookiesOnLogout,
deleteIndexedDBOnLogout,
} from '../store/utils/api';
export default {
login(creds) {
@@ -50,6 +54,7 @@ export default {
axios
.delete(urlData.url)
.then(response => {
deleteIndexedDBOnLogout();
clearCookiesOnLogout();
resolve(response);
})

View File

@@ -1,11 +1,16 @@
/* global axios */
import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
class Inboxes extends ApiClient {
class Inboxes extends CacheEnabledApiClient {
constructor() {
super('inboxes', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'inbox';
}
getCampaigns(inboxId) {
return axios.get(`${this.url}/${inboxId}/campaigns`);
}

View File

@@ -1,9 +1,14 @@
import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
class LabelsAPI extends ApiClient {
class LabelsAPI extends CacheEnabledApiClient {
constructor() {
super('labels', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'label';
}
}
export default new LabelsAPI();

View File

@@ -1,11 +1,27 @@
/* global axios */
import ApiClient from './ApiClient';
// import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
export class TeamsAPI extends ApiClient {
export class TeamsAPI extends CacheEnabledApiClient {
constructor() {
super('teams', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'team';
}
// eslint-disable-next-line class-methods-use-this
extractDataFromResponse(response) {
return response.data;
}
// eslint-disable-next-line class-methods-use-this
marshallData(dataToParse) {
return { data: dataToParse };
}
getAgents({ teamId }) {
return axios.get(`${this.url}/${teamId}/team_members`);
}

View File

@@ -0,0 +1,70 @@
import { openDB } from 'idb';
import { DATA_VERSION } from './version';
export class DataManager {
constructor(accountId) {
this.modelsToSync = ['inbox', 'label', 'team'];
this.accountId = accountId;
this.db = null;
}
async initDb() {
if (this.db) return this.db;
this.db = await openDB(`cw-store-${this.accountId}`, DATA_VERSION, {
upgrade(db) {
db.createObjectStore('cache-keys');
db.createObjectStore('inbox', { keyPath: 'id' });
db.createObjectStore('label', { keyPath: 'id' });
db.createObjectStore('team', { keyPath: 'id' });
},
});
return this.db;
}
validateModel(name) {
if (!name) throw new Error('Model name is not defined');
if (!this.modelsToSync.includes(name)) {
throw new Error(`Model ${name} is not defined`);
}
return true;
}
async replace({ modelName, data }) {
this.validateModel(modelName);
this.db.clear(modelName);
return this.push({ modelName, data });
}
async push({ modelName, data }) {
this.validateModel(modelName);
if (Array.isArray(data)) {
const tx = this.db.transaction(modelName, 'readwrite');
data.forEach(item => {
tx.store.add(item);
});
await tx.done;
} else {
await this.db.add(modelName, data);
}
}
async get({ modelName }) {
this.validateModel(modelName);
return this.db.getAll(modelName);
}
async setCacheKeys(cacheKeys) {
Object.keys(cacheKeys).forEach(async modelName => {
this.db.put('cache-keys', cacheKeys[modelName], modelName);
});
}
async getCacheKey(modelName) {
this.validateModel(modelName);
return this.db.get('cache-keys', modelName);
}
}

View File

@@ -0,0 +1,3 @@
// Monday, 13 March 2023
// Change this version if you want to invalidate old data
export const DATA_VERSION = '1678706392';

View File

@@ -26,6 +26,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'first.reply.created': this.onFirstReplyCreated,
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate,
};
}
@@ -156,6 +157,13 @@ class ActionCableConnector extends BaseActionCableConnector {
onFirstReplyCreated = () => {
bus.$emit('fetch_overview_reports');
};
onCacheInvalidate = data => {
const keys = data.cache_keys;
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
}
export default {

View File

@@ -0,0 +1,114 @@
import { DataManager } from '../../CacheHelper/DataManager';
describe('DataManager', () => {
const accountId = 'test-account';
let dataManager;
beforeAll(async () => {
dataManager = new DataManager(accountId);
await dataManager.initDb();
});
afterEach(async () => {
const tx = dataManager.db.transaction(
dataManager.modelsToSync,
'readwrite'
);
dataManager.modelsToSync.forEach(modelName => {
tx.objectStore(modelName).clear();
});
await tx.done;
});
describe('initDb', () => {
it('should initialize the database', async () => {
expect(dataManager.db).not.toBeNull();
});
it('should return the same instance of the database', async () => {
const db1 = await dataManager.initDb();
const db2 = await dataManager.initDb();
expect(db1).toBe(db2);
});
});
describe('validateModel', () => {
it('should throw an error for empty input', async () => {
expect(() => {
dataManager.validateModel();
}).toThrow();
});
it('should throw an error for invalid model', async () => {
expect(() => {
dataManager.validateModel('invalid-model');
}).toThrow();
});
it('should not throw an error for valid model', async () => {
expect(dataManager.validateModel('label')).toBeTruthy();
});
});
describe('replace', () => {
it('should replace existing data in the specified model', async () => {
const inboxData = [
{ id: 1, name: 'inbox-1' },
{ id: 2, name: 'inbox-2' },
];
const newData = [
{ id: 3, name: 'inbox-3' },
{ id: 4, name: 'inbox-4' },
];
await dataManager.push({ modelName: 'inbox', data: inboxData });
await dataManager.replace({ modelName: 'inbox', data: newData });
const result = await dataManager.get({ modelName: 'inbox' });
expect(result).toEqual(newData);
});
});
describe('push', () => {
it('should add data to the specified model', async () => {
const inboxData = { id: 1, name: 'inbox-1' };
await dataManager.push({ modelName: 'inbox', data: inboxData });
const result = await dataManager.get({ modelName: 'inbox' });
expect(result).toEqual([inboxData]);
});
it('should add multiple items to the specified model if an array of data is provided', async () => {
const inboxData = [
{ id: 1, name: 'inbox-1' },
{ id: 2, name: 'inbox-2' },
];
await dataManager.push({ modelName: 'inbox', data: inboxData });
const result = await dataManager.get({ modelName: 'inbox' });
expect(result).toEqual(inboxData);
});
});
describe('get', () => {
it('should return all data in the specified model', async () => {
const inboxData = [
{ id: 1, name: 'inbox-1' },
{ id: 2, name: 'inbox-2' },
];
await dataManager.push({ modelName: 'inbox', data: inboxData });
const result = await dataManager.get({ modelName: 'inbox' });
expect(result).toEqual(inboxData);
});
});
describe('setCacheKeys', () => {
it('should add cache keys for each model', async () => {
const cacheKeys = { inbox: 'cache-key-1', label: 'cache-key-2' };
await dataManager.setCacheKeys(cacheKeys);
const result = await dataManager.getCacheKey('inbox');
expect(result).toEqual(cacheKeys.inbox);
});
});
});

View File

@@ -126,10 +126,21 @@ const sendAnalyticsEvent = channelType => {
};
export const actions = {
revalidate: async ({ commit }, { newKey }) => {
try {
const isExistingKeyValid = await InboxesAPI.validateCacheKey(newKey);
if (!isExistingKeyValid) {
const response = await InboxesAPI.refetchAndCommit(newKey);
commit(types.default.SET_INBOXES, response.data.payload);
}
} catch (error) {
// Ignore error
}
},
get: async ({ commit }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: true });
try {
const response = await InboxesAPI.get();
const response = await InboxesAPI.get(true);
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: false });
commit(types.default.SET_INBOXES, response.data.payload);
} catch (error) {

View File

@@ -29,10 +29,22 @@ export const getters = {
};
export const actions = {
revalidate: async function revalidate({ commit }, { newKey }) {
try {
const isExistingKeyValid = await LabelsAPI.validateCacheKey(newKey);
if (!isExistingKeyValid) {
const response = await LabelsAPI.refetchAndCommit(newKey);
commit(types.SET_LABELS, response.data.payload);
}
} catch (error) {
// Ignore error
}
},
get: async function getLabels({ commit }) {
commit(types.SET_LABEL_UI_FLAG, { isFetching: true });
try {
const response = await LabelsAPI.get();
const response = await LabelsAPI.get(true);
commit(types.SET_LABELS, response.data.payload);
} catch (error) {
// Ignore error

View File

@@ -22,10 +22,21 @@ export const actions = {
commit(SET_TEAM_UI_FLAG, { isCreating: false });
}
},
revalidate: async ({ commit }, { newKey }) => {
try {
const isExistingKeyValid = await TeamsAPI.validateCacheKey(newKey);
if (!isExistingKeyValid) {
const response = await TeamsAPI.refetchAndCommit(newKey);
commit(SET_TEAMS, response.data);
}
} catch (error) {
// Ignore error
}
},
get: async ({ commit }) => {
commit(SET_TEAM_UI_FLAG, { isFetching: true });
try {
const { data } = await TeamsAPI.get();
const { data } = await TeamsAPI.get(true);
commit(CLEAR_TEAMS);
commit(SET_TEAMS, data);
} catch (error) {

View File

@@ -42,6 +42,13 @@ export const clearLocalStorageOnLogout = () => {
LocalStorage.remove(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES);
};
export const deleteIndexedDBOnLogout = async () => {
const dbs = await window.indexedDB.databases();
dbs.forEach(db => {
window.indexedDB.deleteDatabase(db.name);
});
};
export const clearCookiesOnLogout = () => {
window.bus.$emit(CHATWOOT_RESET);
window.bus.$emit(ANALYTICS_RESET);

View File

@@ -7,6 +7,15 @@ class ActionCableListener < BaseListener
broadcast(account, tokens, NOTIFICATION_CREATED, { notification: notification.push_event_data, unread_count: unread_count, count: count })
end
def account_cache_invalidated(event)
account = event.data[:account]
tokens = user_tokens(account, account.agents)
broadcast(account, tokens, ACCOUNT_CACHE_INVALIDATED, {
cache_keys: event.data[:cache_keys]
})
end
def message_created(event)
message, account = extract_message_and_account(event)
conversation = message.conversation

View File

@@ -26,6 +26,7 @@ class Account < ApplicationRecord
include FlagShihTzu
include Reportable
include Featurable
include CacheKeys
DEFAULT_QUERY_SETTING = {
flag_query_mode: :bit_operator,

View File

@@ -0,0 +1,13 @@
module AccountCacheRevalidator
extend ActiveSupport::Concern
included do
after_save :update_account_cache
after_destroy :update_account_cache
after_create :update_account_cache
end
def update_account_cache
account.update_cache_key(self.class.name.underscore)
end
end

View File

@@ -0,0 +1,32 @@
module CacheKeys
extend ActiveSupport::Concern
include CacheKeysHelper
include Events::Types
def cache_keys
{
label: fetch_value_for_key(id, Label.name.underscore),
inbox: fetch_value_for_key(id, Inbox.name.underscore),
team: fetch_value_for_key(id, Team.name.underscore)
}
end
def invalidate_cache_key_for(key)
prefixed_cache_key = get_prefixed_cache_key(id, key)
Redis::Alfred.del(prefixed_cache_key)
dispatch_cache_udpate_event
end
def update_cache_key(key)
prefixed_cache_key = get_prefixed_cache_key(id, key)
Redis::Alfred.set(prefixed_cache_key, Time.now.utc.to_i)
dispatch_cache_udpate_event
end
private
def dispatch_cache_udpate_event
Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, cache_keys: cache_keys, account: self)
end
end

View File

@@ -34,6 +34,7 @@ class Inbox < ApplicationRecord
include Reportable
include Avatarable
include OutOfOffisable
include AccountCacheRevalidator
# Not allowing characters:
validates :name, presence: true

View File

@@ -18,6 +18,8 @@
#
class Label < ApplicationRecord
include RegexHelper
include AccountCacheRevalidator
belongs_to :account
validates :title,

View File

@@ -16,6 +16,8 @@
# index_teams_on_name_and_account_id (name,account_id) UNIQUE
#
class Team < ApplicationRecord
include AccountCacheRevalidator
belongs_to :account
has_many :team_members, dependent: :destroy_async
has_many :members, through: :team_members, source: :user

View File

@@ -3,6 +3,10 @@ class AccountPolicy < ApplicationPolicy
@account_user.administrator? || @account_user.agent?
end
def cache_keys?
@account_user.administrator? || @account_user.agent?
end
def update?
@account_user.administrator?
end

View File

@@ -14,3 +14,4 @@ json.locale @account.locale
json.name @account.name
json.support_email @account.support_email
json.status @account.status
json.cache_keys @account.cache_keys

View File

@@ -35,6 +35,7 @@ Rails.application.routes.draw do
resources :accounts, only: [:create, :show, :update] do
member do
post :update_active_at
get :cache_keys
end
scope module: :accounts do

View File

@@ -31,9 +31,11 @@ environment.loaders.append('audio', {
},
});
const preserveNameFor = ['sdk', 'worker'];
environment.config.merge({ resolve });
environment.config.set('output.filename', chunkData => {
return chunkData.chunk.name === 'sdk'
return preserveNameFor.includes(chunkData.chunk.name)
? 'js/[name].js'
: 'js/[name]-[hash].js';
});

View File

@@ -32,4 +32,5 @@ module.exports = {
testURL: 'http://localhost/',
globalSetup: './jest.setup.js',
testEnvironment: 'jsdom',
setupFiles: ['fake-indexeddb/auto'],
};

View File

@@ -4,6 +4,7 @@ module Events::Types
### Installation Events ###
# account events
ACCOUNT_CREATED = 'account.created'
ACCOUNT_CACHE_INVALIDATED = 'account.cache_invalidated'
#### Account Events ###
# campaign events

View File

@@ -41,12 +41,13 @@
"dompurify": "2.2.7",
"foundation-sites": "~6.5.3",
"highlight.js": "~10.4.1",
"idb": "^7.1.1",
"ionicons": "~2.0.1",
"js-cookie": "^2.2.1",
"markdown-it": "^13.0.1",
"markdown-it-link-attributes": "^4.0.1",
"logrocket": "^3.0.1",
"logrocket-vuex": "^0.0.3",
"markdown-it": "^13.0.1",
"markdown-it-link-attributes": "^4.0.1",
"md5": "^2.3.0",
"ninja-keys": "^1.1.9",
"opus-recorder": "^8.0.5",
@@ -108,6 +109,7 @@
"eslint-plugin-storybook": "^0.5.13",
"eslint-plugin-vue": "^6.2.2",
"expect-more-jest": "^2.4.2",
"fake-indexeddb": "^4.0.1",
"husky": "^7.0.0",
"jest": "26.6.3",
"jest-serializer-vue": "^2.0.2",

View File

@@ -137,6 +137,22 @@ RSpec.describe 'Accounts API', type: :request do
end
end
describe 'GET /api/v1/accounts/{account.id}/cache_keys' do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
it 'returns cache_keys as expected' do
account.update(auto_resolve_duration: 30)
get "/api/v1/accounts/#{account.id}/cache_keys",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(JSON.parse(response.body)['cache_keys'].keys).to match_array(%w[label inbox team])
end
end
describe 'PUT /api/v1/accounts/{account.id}' do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }

View File

@@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.describe CacheKeysHelper, type: :helper do
let(:account_id) { 1 }
let(:key) { 'example_key' }
describe '#get_prefixed_cache_key' do
it 'returns a string with the correct prefix, account ID, and key' do
expected_key = "idb-cache-key-account-#{account_id}-#{key}"
result = helper.get_prefixed_cache_key(account_id, key)
expect(result).to eq(expected_key)
end
end
describe '#fetch_value_for_key' do
it 'returns the zero epoch time if no value is cached' do
result = helper.fetch_value_for_key(account_id, 'another-key')
expect(result).to eq('0000000000')
end
it 'returns a cached value if it exists' do
value = Time.now.to_i
prefixed_cache_key = helper.get_prefixed_cache_key(account_id, key)
Redis::Alfred.set(prefixed_cache_key, value)
result = helper.fetch_value_for_key(account_id, key)
expect(result).to eq(value.to_s)
end
end
end

View File

@@ -5365,6 +5365,11 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base64-arraybuffer-es6@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.7.0.tgz#dbe1e6c87b1bf1ca2875904461a7de40f21abc86"
integrity sha512-ESyU/U1CFZDJUdr+neHRhNozeCv72Y7Vm0m1DCbjX3KBjT6eYocvAJlSk6+8+HkVwXlT1FNxhGW6q3UKAlCvvw==
base64-js@^1.0.2:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@@ -7260,6 +7265,13 @@ domelementtype@^2.0.1, domelementtype@^2.2.0:
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57"
integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==
domexception@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90"
integrity sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==
dependencies:
webidl-conversions "^4.0.2"
domexception@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304"
@@ -8137,6 +8149,13 @@ extsprintf@^1.2.0:
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
fake-indexeddb@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-4.0.1.tgz#09bb2468e21d0832b2177e894765fb109edac8fb"
integrity sha512-hFRyPmvEZILYgdcLBxVdHLik4Tj3gDTu/g7s9ZDOiU3sTNiGx+vEu1ri/AMsFJUZ/1sdRbAVrEcKndh3sViBcA==
dependencies:
realistic-structured-clone "^3.0.0"
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
@@ -9342,6 +9361,11 @@ icss-utils@^4.0.0, icss-utils@^4.1.1:
dependencies:
postcss "^7.0.14"
idb@^7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b"
integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==
ieee754@^1.1.4:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
@@ -14259,6 +14283,15 @@ readdirp@~3.5.0:
dependencies:
picomatch "^2.2.1"
realistic-structured-clone@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/realistic-structured-clone/-/realistic-structured-clone-3.0.0.tgz#7b518049ce2dad41ac32b421cd297075b00e3e35"
integrity sha512-rOjh4nuWkAqf9PWu6JVpOWD4ndI+JHfgiZeMmujYcPi+fvILUu7g6l26TC1K5aBIp34nV+jE1cDO75EKOfHC5Q==
dependencies:
domexception "^1.0.1"
typeson "^6.1.0"
typeson-registry "^1.0.0-alpha.20"
realpath-native@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-2.0.0.tgz#7377ac429b6e1fd599dc38d08ed942d0d7beb866"
@@ -16077,6 +16110,13 @@ tr46@^2.0.2:
dependencies:
punycode "^2.1.1"
tr46@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240"
integrity sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==
dependencies:
punycode "^2.1.1"
tr46@~0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
@@ -16239,6 +16279,20 @@ typedarray@^0.0.6:
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
typeson-registry@^1.0.0-alpha.20:
version "1.0.0-alpha.39"
resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz#9e0f5aabd5eebfcffd65a796487541196f4b1211"
integrity sha512-NeGDEquhw+yfwNhguLPcZ9Oj0fzbADiX4R0WxvoY8nGhy98IbzQy1sezjoEFWOywOboj/DWehI+/aUlRVrJnnw==
dependencies:
base64-arraybuffer-es6 "^0.7.0"
typeson "^6.0.0"
whatwg-url "^8.4.0"
typeson@^6.0.0, typeson@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/typeson/-/typeson-6.1.0.tgz#5b2a53705a5f58ff4d6f82f965917cabd0d7448b"
integrity sha512-6FTtyGr8ldU0pfbvW/eOZrEtEkczHRUtduBnA90Jh9kMPCiFNnXIon3vF41N0S4tV1HHQt4Hk1j4srpESziCaA==
uc.micro@^1.0.1, uc.micro@^1.0.5:
version "1.0.6"
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac"
@@ -16990,6 +17044,11 @@ webidl-conversions@^3.0.0:
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=
webidl-conversions@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
webidl-conversions@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff"
@@ -17232,6 +17291,15 @@ whatwg-url@^8.0.0, whatwg-url@^8.5.0:
tr46 "^2.0.2"
webidl-conversions "^6.1.0"
whatwg-url@^8.4.0:
version "8.7.0"
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77"
integrity sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==
dependencies:
lodash "^4.7.0"
tr46 "^2.1.0"
webidl-conversions "^6.1.0"
which-boxed-primitive@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"