refactor: 💡 Remove usage of indexed DB (#2979)

* refactor: 💡 Remove usage of indexed DB
This commit is contained in:
Zhihe Li committed 2025-09-03 11:41:43 -04:00
1 parent 4c7ea161cd
commit 9e263379a7
101 files changed
+15 -2682

No files matched your search

@@ -1,240 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { modelIndexes } from 'api/services/indexed-db';
import { service } from '@ember/service';
import { getOwner, setOwner } from '@ember/application';
import { queryIndexedDb } from '../utils/indexed-db-query';
import { paginateResults } from '../utils/paginate-results';
import { sortResults } from '../utils/sort-results';
import { hashCode } from '../utils/hash-code';
/**
* Handler to sit in front of the API request layer
* so we can handle any caching with indexedDb first
*/
export default class IndexedDbHandler {
@service indexedDb;
batchLimit = 10_000;
constructor(context) {
setOwner(this, getOwner(context));
}
async request(context, next) {
const { batchLimit } = this;
switch (context.request.op) {
case 'query': {
const { store, data } = context.request;
// TODO: Remove storeToken option as this is a temporary fix for auth-methods.
const {
type,
query,
options: {
pushToStore = true,
peekDb = false,
storeToken = true,
} = {},
} = data;
const supportedModels = Object.keys(modelIndexes);
const { db: indexedDb } = this.indexedDb ?? {};
// Go through normal flow if we don't yet support the model
// or if we don't have an indexedDb instance
if (!supportedModels.includes(type) || !indexedDb) {
return next(context.request);
}
let { page, pageSize, query: queryObj, ...remainingQuery } = query;
const adapter = store.adapterFor(type);
const schema = store.modelFor(type);
const serializer = store.serializerFor(type);
if (!peekDb) {
const tokenKey = `${type}-${hashCode(remainingQuery)}`;
let payload;
let listToken;
let writeToIndexedDbPromise;
if (storeToken) {
const tokenObj = await indexedDb.token.get(tokenKey);
listToken = tokenObj?.token;
} else {
// This is a temporary fix of clearing the DB (specifically for auth-methods)
// since we are not storing the token we do not get back a list of removed_ids
// from the API call and we do not want deleted items from showing in list view.
await indexedDb[type].clear();
}
do {
try {
payload = await adapter.query(store, schema, {
...remainingQuery,
list_token: listToken,
batchLimit: batchLimit,
});
// await the previous writeToIndexedDbPromise before writing to indexedDb again
if (writeToIndexedDbPromise) {
await writeToIndexedDbPromise;
}
} catch (err) {
payload = await this.retryQueryFailure({
err,
indexedDb,
type,
adapter,
store,
schema,
remainingQuery,
});
}
listToken = payload.list_token;
// Don't await immediately and let the next query run while we write to indexedDb
writeToIndexedDbPromise = this.writeToIndexedDb({
payload,
storeToken,
indexedDb,
tokenKey,
type,
serializer,
store,
schema,
});
} while (payload.response_type === 'delta');
await writeToIndexedDbPromise;
}
// Results returned in JSON API reponse, but in a format optimized for indexedDb
const indexedDbResults = await queryIndexedDb(
indexedDb,
type,
queryObj,
);
// Normalize the results and clean up indexedDb format, ready for ember data consumption
const normalizedResults = indexedDbResults.map((item) => {
return this.indexedDb.normalizeData({
data: item,
cleanData: false,
schema,
serializer,
});
});
// Sort the results
const sortedResults = sortResults(normalizedResults, {
querySort: queryObj?.sort,
schema,
});
// Paginate the results
const paginatedResults = paginateResults(sortedResults, page, pageSize);
// If we are not pushing to the store, use the raw data with id property
const records = pushToStore
? store.push({ data: paginatedResults })
: paginatedResults.map((record) => ({
...record.attributes,
id: record.id,
}));
// Set a meta property on the array to store the total items of the results.
// This isn't conventional but is better than returning an ArrayProxy
// or EmberArray since the ember store query method asserts it has to be an array
// so we can't just return an object.
records.meta = { totalItems: indexedDbResults.length };
return records;
}
default:
return next(context.request);
}
}
async retryQueryFailure({
err,
indexedDb,
type,
adapter,
store,
schema,
remainingQuery,
}) {
// If we get an invalid list token, we'll delete the token and
// clear the DB and try again without a token.
if (
err?.errors[0].status === 400 &&
err?.errors[0].code === 'invalid list token'
) {
await indexedDb[type].clear();
// Delete all tokens of the same resource type so we don't keep clearing the DB.
// Even if other tokens may still be valid, we might have cleared data that they
// depended on so we should clear all tokens of the same type when clearing the DB.
const tokenKeys = await indexedDb.token
.where(':id')
.startsWith(type)
.primaryKeys();
await indexedDb.token.bulkDelete(tokenKeys);
return await adapter.query(store, schema, {
...remainingQuery,
});
} else {
throw err;
}
}
async writeToIndexedDb({
payload,
storeToken,
indexedDb,
tokenKey,
type,
serializer,
store,
schema,
}) {
// Store the token we just got back from the payload if it exists
if (payload.list_token && storeToken) {
await indexedDb.token.put({
id: tokenKey,
token: payload.list_token,
});
}
// Remove any records from the DB if the API indicates they've been deleted
if (payload.removed_ids?.length > 0) {
await indexedDb[type].bulkDelete(payload.removed_ids);
}
const normalizedPayload = serializer.normalizeResponse(
store,
schema,
payload,
null,
'query',
);
const { data: payloadData } = normalizedPayload;
// Store the new data we just got back from the API refresh
const items = payloadData.map((datum) =>
this.indexedDb.normalizeData({
data: datum,
cleanData: true,
schema,
serializer,
}),
);
await indexedDb[type].bulkPut(items);
}
}
-170
View File
@@ -1,170 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Service from '@ember/service';
import Dexie from 'dexie';
import { typeOf } from '@ember/utils';
// List of resources we support to be cached and searched in
// indexedDb. Any field that can be searched upon should be added to
// this index. Increment the version number of the database whenever
// indexes change.
export const modelIndexes = {
token: '&id, token',
target:
'&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.address, attributes.scope.scope_id',
session:
'&id, attributes.created_time, attributes.type, attributes.status, attributes.endpoint, attributes.target_id, attributes.user_id, attributes.scope.scope_id',
user: '&id, attributes.created_time, attributes.name, attributes.description, attributes.scope.scope_id',
group:
'&id, attributes.created_time, attributes.name, attributes.description, attributes.scope.scope_id',
role: '&id, attributes.created_time, attributes.name, attributes.description, attributes.scope.scope_id',
scope:
'&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.scope.scope_id',
'credential-store':
'&id, attributes.created_time, attributes.name, attributes.description, attributes.type, attributes.scope.scope_id',
'host-catalog':
'&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.scope.scope_id, attributes.plugin.name',
'auth-method':
'&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.is_primary, attributes.scope.scope_id',
'session-recording':
'&id, attributes.created_time, attributes.type, attributes.state, attributes.start_time, attributes.end_time, attributes.duration, attributes.scope.scope_id, attributes.create_time_values.user.id, attributes.create_time_values.user.name, attributes.create_time_values.target.id, attributes.create_time_values.target.name, attributes.create_time_values.target.scope.id, attributes.create_time_values.target.scope.name',
alias:
'&id, attributes.created_time, attributes.type, attributes.value, attributes.name, attributes.description, attributes.destination_id, attributes.scope.scope_id',
};
export const formatDbName = (userId, clusterUrl) =>
`boundary-${userId}-${clusterUrl}`;
/**
* Recursively copy over the object and convert any booleans to "true" or "false" so they can be indexed.
* Also converts any ember-like arrays back to a standard array.
* @param result
* @param object
* @returns {*}
*/
const convertFields = (result, object) => {
Object.entries(object).forEach(([key, value]) => {
if (typeOf(value) === 'object') {
result[key] = convertFields({}, value);
} else if (typeOf(value) === 'boolean') {
result[key] = value ? 'true' : 'false';
} else if (typeOf(value) === 'array') {
result[key] = [...value];
} else {
result[key] = value;
}
});
return result;
};
/**
* Recursively copy over the object and convert any string "true" or "false" back to booleans.
* Will also convert any array fields back using their model transformation.
* @param result
* @param object
* @param schema
* @param serializer
* @returns {*}
*/
const unconvertFields = (result, object, schema, serializer) => {
Object.entries(object).forEach(([key, value]) => {
if (typeOf(value) === 'object') {
result[key] = unconvertFields({}, value, schema, serializer);
} else if (value === 'true' || value === 'false') {
result[key] = value === 'true';
} else if (typeOf(value) === 'array') {
const attribute = schema.attributes.get(key);
// If attribute type is array, we transform it back to a tracked array
if (attribute?.type === 'array') {
const transformer = serializer.transformFor(attribute.type);
result[key] = transformer.deserialize(value, attribute.options);
return;
}
result[key] = value;
} else {
result[key] = value;
}
});
return result;
};
/**
* Service to encapsulate the IndexedDB implementation. To use this service, call
* `setup` from the application root. This will create the database and the necessary indexes.
*
* Make sure to increment the version number of the database whenever the indexes change.
* This will delete the old database and create a new one with the new indexes.
*/
export default class IndexedDbService extends Service {
// =attributes
#db;
#version = 2;
get db() {
return this.#db;
}
async setup(dbName) {
// Don't run setup again if we already have one or if we didn't get a name
if (this.#db || !dbName) {
return;
}
const doesDbExist = await Dexie.exists(dbName);
// If the database already exists, open the database and
// check if the version is a lower version than the current one
if (doesDbExist) {
const dbVerifier = new Dexie(dbName);
await dbVerifier.open();
if (dbVerifier.verno < this.#version) {
await dbVerifier.delete();
} else {
dbVerifier.close();
}
}
this.#db = new Dexie(dbName);
this.#db.version(this.#version).stores(modelIndexes);
}
/**
* Cleanup the data by converting all fields to plain javascript objects.
* We will also convert booleans to "false" or "true".
* IndexedDB doesn't support indexes on boolean values so we have to
* convert them to be able to support using boolean values.
*
* Undo the operation by setting `convertFields` to false.
*
* @param {Object} data
* @param {Boolean} cleanData
* @param {Object} schema
* @param {Object} serializer
* @return {{id, type, attributes: {}, relationships: {}}}
*/
normalizeData({ data, cleanData, schema, serializer }) {
if (!data) {
return data;
}
let { id, type, attributes = {}, relationships = {} } = data;
return {
id,
type,
attributes: cleanData
? convertFields({}, attributes)
: unconvertFields({}, attributes, schema, serializer),
relationships: cleanData
? convertFields({}, relationships)
: unconvertFields({}, relationships, schema, serializer),
};
}
}
+3
View File
@@ -6,6 +6,9 @@
import Service from '@ember/service';
import { PWBHost } from 'promise-worker-bi';
export const formatDbName = (userId, clusterUrl) =>
`boundary-${userId}-${clusterUrl}`;
// A mapping of columns to their expected model attributes for supported models
// The JSON data column is not included here but is assumed to always be last.
export const modelMapping = {
+1 -7
View File
@@ -10,7 +10,6 @@ import Store from 'ember-data/store';
import { CacheHandler } from '@ember-data/store';
import RequestManager from '@ember-data/request';
import { LegacyNetworkHandler } from '@ember-data/legacy-compat';
import IndexedDbHandler from 'api/handlers/indexed-db-handler';
import SqliteHandler from 'api/handlers/sqlite-handler';
export default class extends Store {
@@ -19,14 +18,9 @@ export default class extends Store {
constructor(args) {
super(args);
const indexedDbHandler = new IndexedDbHandler(this);
const sqliteHandler = new SqliteHandler(this);
this.requestManager.use([
sqliteHandler,
indexedDbHandler,
LegacyNetworkHandler,
]);
this.requestManager.use([sqliteHandler, LegacyNetworkHandler]);
this.requestManager.useCache(CacheHandler);
}
}
-258
View File
@@ -1,258 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { get } from '@ember/object';
import { modelIndexes } from '../services/indexed-db';
/**
* Takes a POJO representing a filter query and builds a query to
* query indexedDb with. Note that the first filter is important as that
* will use the actual index in indexedDb while any subsequent ones will be less efficient.
*
* @example
* const filter = queryIndexedDb(indexedDb, type, {
* search: generated',
* filters: {
* scope_id: {
* logicalOperator: 'and',
* values: [{ notEquals: 'scope1' }, { notEquals: 'scope2' }],
* },
* },
* });
*/
export async function queryIndexedDb(indexedDb, resource, query) {
let { search, filters = {} } = query ?? {};
let filterCollection = indexedDb[resource];
search = getSearchObject(search, resource);
filters = cleanObject(filters);
if (Object.keys(filters).length) {
// Remove the first item to use as the initial collection and then reduce the remaining ones
const entries = Object.entries(filters);
const [firstKey, firstFilterArrayOrObject] = entries.shift();
filterCollection = await entries.reduce(
async (currCollection, [key, filterArrayOrObject]) => {
// Grab the results from the set of one filters to be `AND` together with the rest of the filters
const filterResults = await buildInitialWhereClause({
table: indexedDb[resource],
key,
filterArrayOrObject,
}).primaryKeys();
const resultIdsSet = new Set(filterResults);
return (await currCollection).and((record) => {
return resultIdsSet.has(record.id);
});
},
buildInitialWhereClause({
key: firstKey,
table: indexedDb[resource],
filterArrayOrObject: firstFilterArrayOrObject,
}),
);
}
if (search) {
const { text, fields } = search;
filterCollection = filterCollection.filter((record) =>
fields
.map((key) => get(record, key))
.some((element) => {
if (typeof element === 'string') {
return element?.toLowerCase().includes(text?.toLowerCase());
}
}),
);
}
// filterCollection can be Dexie Table or a Dexie Collection.
// This is an important detail you need to keep in mind when working on it.
return filterCollection.toArray();
}
const buildInitialWhereClause = ({ filterArrayOrObject, table, key }) => {
const filterValueArray = Array.isArray(filterArrayOrObject)
? filterArrayOrObject
: filterArrayOrObject.values;
const { logicalOperator } = filterArrayOrObject;
// Short circuit if all the filter types were equals or notEquals since indexedDb
// can more efficiently query if we pass all values at once
const checkAllFilterType = (type) =>
filterValueArray.every((obj) => Object.keys(obj)[0] === type);
if (checkAllFilterType('equals')) {
return table
.where(getKey(key))
.anyOf(filterValueArray.map((obj) => obj['equals']));
}
if (checkAllFilterType('notEquals')) {
return table
.where(getKey(key))
.noneOf(filterValueArray.map((obj) => obj['notEquals']));
}
// Remove the first item to use as the initial where clause and then reduce the remaining ones
const [firstOperation, firstFilterValue] = Object.entries(
filterValueArray[0],
)[0];
// Not all are filters are equals or notEquals,
// manually build the initial where clause and resulting collections
return filterValueArray.slice(1).reduce(
(result, currObj) => {
const [operation, value] = Object.entries(currObj)[0];
return buildIndexedDbCollection({
key: getKey(key),
value,
collection: result,
operation,
logicalOperator,
});
},
buildIndexedDbWhere({
table,
key: getKey(key),
operation: firstOperation,
value: firstFilterValue,
}),
);
};
/**
* Builds the initial where clause for indexed DB to filter on. Note that this makes
* the first filter important as that will do the real filtering from indexed DB while
* any subsequent filter has to be done in memory.
*/
const buildIndexedDbWhere = ({ table, key, value, operation }) => {
const baseWhereClause = table.where(key);
switch (operation) {
case 'contains':
// We return an error due to not being able to support contains on first filter due to performance.
throw new Error('Contains is not supported as the first filter.');
case 'gt':
return baseWhereClause.above(value);
case 'gte':
return baseWhereClause.aboveOrEqual(value);
case 'lt':
return baseWhereClause.below(value);
case 'lte':
return baseWhereClause.belowOrEqual(value);
case 'notEquals':
return baseWhereClause.notEqual(value);
default:
return baseWhereClause.equals(value);
}
};
/**
* Continues building the queries for indexed DB to filter on. Note that these are not true
* indexed DB queries and in fact just standard JS filters for logical "and" queries and parallel queries
* to indexed DB for logical "or" queries that get deduped.
*/
const buildIndexedDbCollection = ({
key,
value,
collection,
operation,
logicalOperator,
}) => {
if (logicalOperator === 'and') {
switch (operation) {
case 'contains':
return collection.and((record) => get(record, key)?.includes(value));
case 'gt':
return collection.and((record) => get(record, key) > value);
case 'gte':
return collection.and((record) => get(record, key) >= value);
case 'lt':
return collection.and((record) => get(record, key) < value);
case 'lte':
return collection.and((record) => get(record, key) <= value);
case 'notEquals':
return collection.and((record) => get(record, key) !== value);
default:
return collection.and((record) => get(record, key) === value);
}
}
switch (operation) {
case 'contains':
// We return an error due to not being able to support contains on this filter due to performance.
throw new Error(
'Contains is not supported as a filter option with "or" operator.',
);
case 'gt':
return collection.or(key).above(value);
case 'gte':
return collection.or(key).aboveOrEqual(value);
case 'lt':
return collection.or(key).below(value);
case 'lte':
return collection.or(key).belowOrEqual(value);
case 'notEquals':
return collection.or(key).notEqual(value);
default:
return collection.or(key).equals(value);
}
};
/**
* Translates the fields passed in from the UI for resources to the keys indexed DB knows them as.
* Usually it's the path of the actual normalized model.
*/
const getKey = (key) => {
switch (key) {
case 'scope_id':
return `attributes.scope.${key}`;
case 'id':
return key;
default:
return `attributes.${key}`;
}
};
/**
* Cleans the object to remove any keys that have a value of an empty array. This makes
* it easier so we don't have to worry about empty arrays getting queried in indexed db.
*/
const cleanObject = (obj) => {
if (!obj) {
return {};
}
return Object.entries(obj).reduce((result, [key, value]) => {
if (Array.isArray(value)) {
if (value.length > 0) {
result[key] = value;
}
} else if (value.values.length > 0) {
result[key] = value;
}
return result;
}, {});
};
/**
* Makes the search object that is expected when a user searches. Uses
* the indexes we setup in indexed db.
*/
const getSearchObject = (text, resource) => {
if (!text) {
return null;
}
return {
text,
fields: modelIndexes[resource]
.split(', ')
.map((index) => index.replace('&', '')),
};
};
-116
View File
@@ -1,116 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
// created_time is not an attribute defined on all models, but is present in all the resources
// coming from API
const SORT_DEFAULT_ATTRIBUTE = 'created_time';
const SORT_DIRECTION_ASCENDING = 'asc';
const SORT_DIRECTION_DESCENDING = 'desc';
const sortFunctions = {
string: (a, b) =>
String(a).localeCompare(String(b), navigator.language, {
caseFirst: 'upper',
}),
date: (a, b) => new Date(a).getTime() - new Date(b).getTime(),
number: (a, b) => Number(a) - Number(b),
boolean: (a, b) => Number(a) - Number(b),
};
function getSortableValue(schema, record, attribute) {
if (attribute === 'id') {
return record.id;
}
// all models have a created_time attribute but not all models have it defined
if (attribute === 'created_time') {
return record.attributes.created_time;
}
if (schema.attributes.has(attribute)) {
return record.attributes[attribute];
}
const attributes = Array.from(schema.attributes.keys());
throw new Error(
`The attribute "${attribute}" does not map to the model definition of type "${record.type}". Supported sortable attributes are 'id', 'created_time', or ${attributes.map((attr) => `'${attr}'`).join(', ')}`,
);
}
export const sortResults = (results, { querySort, schema }) => {
querySort = querySort ?? {};
const sortAttribute = querySort.attributes?.[0] || SORT_DEFAULT_ATTRIBUTE;
// Default sort direction is ascending unless we are sorting by `created_time` (default sort attribute)
const defaultSortDirection =
sortAttribute === SORT_DEFAULT_ATTRIBUTE
? SORT_DIRECTION_DESCENDING
: SORT_DIRECTION_ASCENDING;
const sortDirection = querySort.direction || defaultSortDirection;
if (
![SORT_DIRECTION_ASCENDING, SORT_DIRECTION_DESCENDING].includes(
sortDirection,
)
) {
throw new Error('Invalid sort direction');
}
const sortAttributeDataType = schema.attributes.get(sortAttribute)?.type;
const sortFunction =
sortFunctions[sortAttributeDataType] ?? sortFunctions.string;
// Execute custom sort for multiple attributes provided.
if (querySort.attributes?.length > 1) {
const { attributes } = querySort;
return results.toSorted((a, b) => {
let sortValueA, sortValueB;
attributes.forEach((attr) => {
const valueA = getSortableValue(schema, a, attr);
const valueB = getSortableValue(schema, b, attr);
if (valueA && !sortValueA) {
sortValueA = valueA;
}
if (valueB && !sortValueB) {
sortValueB = valueB;
}
});
const sortResult = sortFunction(sortValueA, sortValueB);
return sortDirection === SORT_DIRECTION_ASCENDING
? sortResult
: -1 * sortResult;
});
}
// Execute custom sort for attribute map provided.
if (querySort.customSort) {
const { attributeMap } = querySort.customSort;
return results.toSorted((a, b) => {
const sortValueA = getSortableValue(schema, a, sortAttribute);
const sortValueB = getSortableValue(schema, b, sortAttribute);
const sortResult = sortFunction(
attributeMap[sortValueA],
attributeMap[sortValueB],
);
return sortDirection === SORT_DIRECTION_ASCENDING
? sortResult
: -1 * sortResult;
});
}
return results.toSorted((a, b) => {
// Extract the values to sort and sort them.
const sortValueA = getSortableValue(schema, a, sortAttribute);
const sortValueB = getSortableValue(schema, b, sortAttribute);
const sortResult = sortFunction(sortValueA, sortValueB);
return sortDirection === SORT_DIRECTION_ASCENDING
? sortResult
: -1 * sortResult;
});
};