* feat: 🎸 Add id auto completion * style: 💄 Fix code editor styling for dark mode * feat: 🎸 Add optimization for caching sqlite data ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-18506
309 lines
9.3 KiB
JavaScript
309 lines
9.3 KiB
JavaScript
/**
|
|
* Copyright IBM Corp. 2021, 2026
|
|
* SPDX-License-Identifier: BUSL-1.1
|
|
*/
|
|
|
|
import { getOwner, setOwner } from '@ember/owner';
|
|
import { service } from '@ember/service';
|
|
import { modelMapping } from 'api/services/sqlite';
|
|
import { get } from '@ember/object';
|
|
import { typeOf } from '@ember/utils';
|
|
import { hashCode } from '../utils/hash-code';
|
|
import { generateSQLExpressions } from '../utils/sqlite-query';
|
|
|
|
const isISODateString = (str) =>
|
|
typeOf(str) === 'string' &&
|
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z?$/.test(str);
|
|
|
|
export default class SqliteHandler {
|
|
@service sqlite;
|
|
|
|
batchLimit = 5_000;
|
|
|
|
constructor(context) {
|
|
setOwner(this, getOwner(context));
|
|
}
|
|
|
|
async request(context, next) {
|
|
switch (context.request.op) {
|
|
case 'query': {
|
|
const { store, data } = context.request;
|
|
const {
|
|
type,
|
|
query,
|
|
options: {
|
|
pushToStore = true,
|
|
peekDb = false,
|
|
storeToken = true,
|
|
} = {},
|
|
} = data;
|
|
const supportedModels = Object.keys(modelMapping);
|
|
|
|
// Go through normal flow if we don't yet support the model
|
|
// or if we don't have a sqlite db instance
|
|
if (!supportedModels.includes(type) || !this.sqlite.worker) {
|
|
return next(context.request);
|
|
}
|
|
|
|
const adapter = store.adapterFor(type);
|
|
const schema = store.modelFor(type);
|
|
const serializer = store.serializerFor(type);
|
|
|
|
let {
|
|
page,
|
|
pageSize,
|
|
select,
|
|
query: queryObj,
|
|
...remainingQuery
|
|
} = query;
|
|
let payload,
|
|
listToken,
|
|
writeToDbPromise,
|
|
totalInsert = 0,
|
|
rateLimitWarning;
|
|
|
|
if (!peekDb) {
|
|
let tokenKey = `${type}-${hashCode(remainingQuery)}`;
|
|
// The global token key represents a recursive query from global scope,
|
|
// which pulls back every resource of this type.
|
|
const globalTokenKey = `${type}-${hashCode({ scope_id: 'global', recursive: true })}`;
|
|
if (storeToken) {
|
|
// Always prefer the global token when it exists for
|
|
// any more fine-grained query. The global query already fetched every
|
|
// item for this resource type, so we should just re-use it.
|
|
if (tokenKey !== globalTokenKey) {
|
|
const [globalTokenObj] = await this.sqlite.fetchResource({
|
|
...generateSQLExpressions('token', {
|
|
filters: { id: [{ equals: globalTokenKey }] },
|
|
}),
|
|
});
|
|
if (globalTokenObj?.token) {
|
|
tokenKey = globalTokenKey;
|
|
listToken = globalTokenObj.token;
|
|
}
|
|
}
|
|
|
|
// Only look up the specific token if no global token was found.
|
|
if (!listToken) {
|
|
const [tokenObj] = await this.sqlite.fetchResource({
|
|
...generateSQLExpressions('token', {
|
|
filters: { id: [{ equals: 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 this.sqlite.deleteResource(type);
|
|
}
|
|
|
|
do {
|
|
try {
|
|
payload = await adapter.query(store, schema, {
|
|
...remainingQuery,
|
|
list_token: listToken,
|
|
batchLimit: this.batchLimit,
|
|
});
|
|
totalInsert += payload.items?.length ?? 0;
|
|
|
|
// Propagate any rate-limit warning the adapter attached
|
|
if (payload.meta?.rateLimitWarning) {
|
|
rateLimitWarning = payload.meta.rateLimitWarning;
|
|
}
|
|
|
|
// await the previous writeToDbPromise before writing to db again
|
|
if (writeToDbPromise) {
|
|
await writeToDbPromise;
|
|
}
|
|
} catch (err) {
|
|
payload = await this.retryQueryFailure({
|
|
err,
|
|
type,
|
|
adapter,
|
|
store,
|
|
schema,
|
|
remainingQuery,
|
|
});
|
|
totalInsert += payload.items?.length ?? 0;
|
|
}
|
|
listToken = payload.list_token;
|
|
|
|
writeToDbPromise = this.writeToDb(
|
|
payload,
|
|
storeToken,
|
|
tokenKey,
|
|
pushToStore,
|
|
serializer,
|
|
store,
|
|
schema,
|
|
type,
|
|
);
|
|
} while (payload.response_type === 'delta');
|
|
|
|
await writeToDbPromise;
|
|
}
|
|
|
|
if (totalInsert > 0) {
|
|
// If there were any inserts, let sqlite handle running analyze on the DB
|
|
await this.sqlite.analyzeDatabase();
|
|
}
|
|
|
|
const { sql, parameters } = generateSQLExpressions(type, queryObj, {
|
|
page,
|
|
pageSize,
|
|
select: select ?? [{ field: 'data' }],
|
|
});
|
|
|
|
const rows = await this.sqlite.fetchResource({
|
|
sql,
|
|
parameters,
|
|
});
|
|
|
|
const { sql: countSql, parameters: countParams } =
|
|
generateSQLExpressions(type, queryObj, {
|
|
select: [{ field: '*', isCount: true, alias: 'total' }],
|
|
});
|
|
const count = await this.sqlite.fetchResource({
|
|
sql: countSql,
|
|
parameters: countParams,
|
|
});
|
|
|
|
const results = rows.map((item) =>
|
|
JSON.parse(item.data, (key, value) =>
|
|
// Reviver function to convert any ISO strings back to date objects
|
|
// as we had to initially convert them to strings when stored in SQLite
|
|
isISODateString(value) ? new Date(value) : value,
|
|
),
|
|
);
|
|
|
|
// If we are not pushing to the store, use the raw data with id property
|
|
const records = pushToStore
|
|
? store.push({ data: results })
|
|
: results.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: count[0].total,
|
|
};
|
|
|
|
if (rateLimitWarning) {
|
|
records.meta.rateLimitWarning = rateLimitWarning;
|
|
}
|
|
|
|
return records;
|
|
}
|
|
default:
|
|
return next(context.request);
|
|
}
|
|
}
|
|
|
|
async retryQueryFailure({
|
|
err,
|
|
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 this.sqlite.deleteResource(type);
|
|
// 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 { sql, parameters } = generateSQLExpressions('token', {
|
|
filters: { id: [{ contains: type }] },
|
|
});
|
|
const tokenRows = await this.sqlite.fetchResource({
|
|
sql,
|
|
parameters,
|
|
});
|
|
const tokenIds = tokenRows.map((row) => row.id);
|
|
|
|
if (tokenIds.length > 0) {
|
|
await this.sqlite.deleteResource('token', tokenIds);
|
|
}
|
|
|
|
return await adapter.query(store, schema, {
|
|
...remainingQuery,
|
|
});
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async writeToDb(
|
|
payload,
|
|
storeToken,
|
|
tokenKey,
|
|
pushToStore,
|
|
serializer,
|
|
store,
|
|
schema,
|
|
type,
|
|
) {
|
|
if (payload.list_token && storeToken) {
|
|
await this.sqlite.insertResource('token', [
|
|
[tokenKey, payload.list_token],
|
|
]);
|
|
}
|
|
|
|
const normalizedPayload = serializer.normalizeResponse(
|
|
store,
|
|
schema,
|
|
payload,
|
|
null,
|
|
'query',
|
|
);
|
|
|
|
const { data: payloadData } = normalizedPayload;
|
|
|
|
// Remove any records from the DB if the API indicates they've been deleted
|
|
// Additionally remove deleted records from ember data store for symmetry
|
|
if (payload.removed_ids?.length > 0) {
|
|
await this.sqlite.deleteResource(type, payload.removed_ids);
|
|
|
|
if (pushToStore) {
|
|
payload.removed_ids.forEach((id) => {
|
|
const record = store.peekRecord(type, id);
|
|
store.unloadRecord(record);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Store the new data we just got back from the API refresh
|
|
const items = payloadData.map((datum) => {
|
|
const params = Object.values(modelMapping[type]).map((key) => {
|
|
let value;
|
|
|
|
if (key === 'id') {
|
|
value = datum.id;
|
|
} else {
|
|
value = get(datum, `attributes.${key}`);
|
|
}
|
|
|
|
if (typeOf(value) === 'date') {
|
|
return value.toISOString();
|
|
}
|
|
return value;
|
|
});
|
|
|
|
return [...params, JSON.stringify(datum)];
|
|
});
|
|
|
|
await this.sqlite.insertResource(type, items);
|
|
}
|
|
}
|