2025-07-18 10:54:30 -04:00
|
|
|
/**
|
2026-01-28 17:09:06 -08:00
|
|
|
* Copyright IBM Corp. 2021, 2026
|
2025-07-18 10:54:30 -04:00
|
|
|
* SPDX-License-Identifier: BUSL-1.1
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { getOwner, setOwner } from '@ember/owner';
|
2025-08-06 11:09:14 -04:00
|
|
|
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';
|
2025-07-18 10:54:30 -04:00
|
|
|
|
2025-08-27 17:42:15 -04:00
|
|
|
const isISODateString = (str) =>
|
|
|
|
|
typeOf(str) === 'string' &&
|
|
|
|
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z?$/.test(str);
|
|
|
|
|
|
2025-07-18 10:54:30 -04:00
|
|
|
export default class SqliteHandler {
|
2025-08-06 11:09:14 -04:00
|
|
|
@service sqlite;
|
|
|
|
|
|
|
|
|
|
batchLimit = 5_000;
|
|
|
|
|
|
2025-07-18 10:54:30 -04:00
|
|
|
constructor(context) {
|
|
|
|
|
setOwner(this, getOwner(context));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async request(context, next) {
|
|
|
|
|
switch (context.request.op) {
|
2025-08-06 11:09:14 -04:00
|
|
|
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
|
2025-08-27 11:13:53 -04:00
|
|
|
if (!supportedModels.includes(type) || !this.sqlite.worker) {
|
2025-08-06 11:09:14 -04:00
|
|
|
return next(context.request);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const adapter = store.adapterFor(type);
|
|
|
|
|
const schema = store.modelFor(type);
|
|
|
|
|
const serializer = store.serializerFor(type);
|
|
|
|
|
|
2025-10-31 16:58:05 -04:00
|
|
|
let {
|
|
|
|
|
page,
|
|
|
|
|
pageSize,
|
|
|
|
|
select,
|
|
|
|
|
query: queryObj,
|
|
|
|
|
...remainingQuery
|
|
|
|
|
} = query;
|
2025-08-06 11:09:14 -04:00
|
|
|
let payload,
|
|
|
|
|
listToken,
|
|
|
|
|
writeToDbPromise,
|
|
|
|
|
totalInsert = 0;
|
|
|
|
|
|
|
|
|
|
if (!peekDb) {
|
|
|
|
|
const tokenKey = `${type}-${hashCode(remainingQuery)}`;
|
|
|
|
|
if (storeToken) {
|
|
|
|
|
const [tokenObj] = await this.sqlite.fetchResource({
|
|
|
|
|
...generateSQLExpressions('token', {
|
|
|
|
|
filters: { id: [{ equals: tokenKey }] },
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
listToken = tokenObj?.token;
|
2025-08-27 11:13:53 -04:00
|
|
|
} 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);
|
2025-08-06 11:09:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
try {
|
|
|
|
|
payload = await adapter.query(store, schema, {
|
|
|
|
|
...remainingQuery,
|
|
|
|
|
list_token: listToken,
|
|
|
|
|
batchLimit: this.batchLimit,
|
|
|
|
|
});
|
|
|
|
|
totalInsert += payload.items?.length ?? 0;
|
|
|
|
|
|
|
|
|
|
// await the previous writeToDbPromise before writing to db again
|
|
|
|
|
if (writeToDbPromise) {
|
|
|
|
|
await writeToDbPromise;
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
2025-08-27 11:13:53 -04:00
|
|
|
payload = await this.retryQueryFailure({
|
|
|
|
|
err,
|
|
|
|
|
type,
|
|
|
|
|
adapter,
|
|
|
|
|
store,
|
|
|
|
|
schema,
|
|
|
|
|
remainingQuery,
|
|
|
|
|
});
|
|
|
|
|
totalInsert += payload.items?.length ?? 0;
|
2025-08-06 11:09:14 -04:00
|
|
|
}
|
|
|
|
|
listToken = payload.list_token;
|
|
|
|
|
|
|
|
|
|
writeToDbPromise = this.writeToDb(
|
|
|
|
|
payload,
|
|
|
|
|
storeToken,
|
|
|
|
|
tokenKey,
|
2025-11-11 11:16:12 -08:00
|
|
|
pushToStore,
|
2025-08-06 11:09:14 -04:00
|
|
|
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,
|
2025-10-31 16:58:05 -04:00
|
|
|
select: select ?? [{ field: 'data' }],
|
2025-08-06 11:09:14 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const rows = await this.sqlite.fetchResource({
|
|
|
|
|
sql,
|
|
|
|
|
parameters,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { sql: countSql, parameters: countParams } =
|
|
|
|
|
generateSQLExpressions(type, queryObj, {
|
2025-10-31 16:58:05 -04:00
|
|
|
select: [{ field: '*', isCount: true, alias: 'total' }],
|
2025-08-06 11:09:14 -04:00
|
|
|
});
|
|
|
|
|
const count = await this.sqlite.fetchResource({
|
|
|
|
|
sql: countSql,
|
|
|
|
|
parameters: countParams,
|
|
|
|
|
});
|
|
|
|
|
|
2025-08-27 17:42:15 -04:00
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
);
|
2025-08-06 11:09:14 -04:00
|
|
|
|
|
|
|
|
// 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 };
|
|
|
|
|
return records;
|
|
|
|
|
}
|
2025-07-18 10:54:30 -04:00
|
|
|
default:
|
|
|
|
|
return next(context.request);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-06 11:09:14 -04:00
|
|
|
|
2025-08-27 11:13:53 -04:00
|
|
|
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 (
|
2025-08-29 15:30:07 -04:00
|
|
|
err?.errors?.[0].status === 400 &&
|
|
|
|
|
err?.errors?.[0].code === 'invalid list token'
|
2025-08-27 11:13:53 -04:00
|
|
|
) {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-06 11:09:14 -04:00
|
|
|
async writeToDb(
|
|
|
|
|
payload,
|
|
|
|
|
storeToken,
|
|
|
|
|
tokenKey,
|
2025-11-11 11:16:12 -08:00
|
|
|
pushToStore,
|
2025-08-06 11:09:14 -04:00
|
|
|
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;
|
|
|
|
|
|
2025-11-11 11:16:12 -08:00
|
|
|
// 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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-06 11:09:14 -04:00
|
|
|
// 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);
|
|
|
|
|
}
|
2025-07-18 10:54:30 -04:00
|
|
|
}
|