@travetto/model-elasticsearch
Version:
Elasticsearch backing for the travetto model module, with real-time modeling support for Elasticsearch mappings.
555 lines (472 loc) • 19.3 kB
text/typescript
import { Client, errors, type estypes } from '@elastic/elasticsearch';
import {
type ModelCrudSupport, type BulkOperation, type BulkResponse, type ModelBulkSupport, type ModelExpirySupport,
type ModelIndexedSupport, type ModelType, type ModelStorageSupport, NotFoundError, ModelRegistryIndex, type OptionalId,
ModelCrudUtil, ModelIndexedUtil, ModelStorageUtil, ModelExpiryUtil, ModelBulkUtil,
} from '@travetto/model';
import { ShutdownManager, type DeepPartial, type Class, castTo, asFull, TypedObject, asConstructable } from '@travetto/runtime';
import { BindUtil } from '@travetto/schema';
import { Injectable } from '@travetto/di';
import {
type ModelQuery, type ModelQueryCrudSupport, type ModelQueryFacetSupport,
type ModelQuerySupport, type PageableModelQuery, type Query, type ValidStringFields,
QueryVerifier, type ModelQuerySuggestSupport,
ModelQueryUtil, ModelQuerySuggestUtil, ModelQueryCrudUtil,
type ModelQueryFacet,
} from '@travetto/model-query';
import type { ElasticsearchModelConfig } from './config.ts';
import type { EsBulkError } from './internal/types.ts';
import { ElasticsearchQueryUtil } from './internal/query.ts';
import { ElasticsearchSchemaUtil } from './internal/schema.ts';
import { IndexManager } from './index-manager.ts';
/**
* Elasticsearch model source.
*/
export class ElasticsearchModelService implements
ModelCrudSupport, ModelIndexedSupport,
ModelStorageSupport, ModelBulkSupport,
ModelExpirySupport,
ModelQuerySupport, ModelQueryCrudSupport,
ModelQuerySuggestSupport, ModelQueryFacetSupport {
idSource = ModelCrudUtil.uuidSource();
client: Client;
manager: IndexManager;
config: ElasticsearchModelConfig;
constructor(config: ElasticsearchModelConfig) { this.config = config; }
/**
* Directly run the search
*/
async execSearch<T extends ModelType>(cls: Class<T>, search: estypes.SearchRequest): Promise<estypes.SearchResponse<T>> {
let query = search.query;
if (query && Object.keys(query).length === 0) {
query = undefined;
}
try {
const result = await this.client.search<T>({
...this.manager.getIdentity(cls),
...search,
query
});
return result;
} catch (error) {
if (error instanceof errors.ResponseError && error.meta.body && typeof error.meta.body === 'object' && 'error' in error.meta.body) {
console.error(error.meta.body.error);
}
throw error;
}
}
preUpdate(item: { id: string }): string;
preUpdate(item: {}): undefined;
preUpdate(item: { id?: string }): string | undefined {
if ('id' in item && typeof item.id === 'string') {
const id = item.id;
if (!this.config.storeId) {
delete item.id;
}
return id;
}
return;
}
postUpdate<T extends ModelType>(item: T, id?: string): T {
if (!this.config.storeId) {
item.id = id!;
}
return item;
}
/**
* Convert _id to id
*/
async postLoad<T extends ModelType>(cls: Class<T>, input: estypes.SearchHit<T> | estypes.GetGetResult<T>): Promise<T> {
let item = {
...(input._id ? { id: input._id } : {}),
...input._source!,
};
item = await ModelCrudUtil.load(cls, item);
const { expiresAt } = ModelRegistryIndex.getConfig(cls);
if (expiresAt) {
const expiry = ModelExpiryUtil.getExpiryState(cls, item);
if (!expiry.expired) {
return item;
}
throw new NotFoundError(cls, item.id);
} else {
return item;
}
}
async postConstruct(this: ElasticsearchModelService): Promise<void> {
this.client = new Client({
nodes: this.config.hosts,
...(this.config.options || {}),
});
await this.client.cluster.health({});
this.manager = new IndexManager(this.config, this.client);
await ModelStorageUtil.storageInitialization(this.manager);
ShutdownManager.signal.addEventListener('abort', () => this.client.close());
ModelExpiryUtil.registerCull(this);
}
createStorage(): Promise<void> { return this.manager.createStorage(); }
deleteStorage(): Promise<void> { return this.manager.deleteStorage(); }
upsertModel(cls: Class): Promise<void> { return this.manager.upsertModel(cls); }
exportModel(cls: Class): Promise<string> { return this.manager.exportModel(cls); }
deleteModel(cls: Class): Promise<void> { return this.manager.deleteModel(cls); }
truncateModel(cls: Class): Promise<void> { return this.deleteByQuery(cls, {}).then(() => { }); }
async get<T extends ModelType>(cls: Class<T>, id: string): Promise<T> {
try {
const result = await this.client.get<T>({ ...this.manager.getIdentity(cls), id });
return this.postLoad(cls, result);
} catch {
throw new NotFoundError(cls, id);
}
}
async delete<T extends ModelType>(cls: Class<T>, id: string): Promise<void> {
ModelCrudUtil.ensureNotSubType(cls);
try {
const result = await this.client.delete({
...this.manager.getIdentity(cls),
id,
refresh: true,
});
if (result.result === 'not_found') {
throw new NotFoundError(cls, id);
}
} catch (error) {
if (error && error instanceof errors.ResponseError && error.body && error.body.result === 'not_found') {
throw new NotFoundError(cls, id);
}
throw error;
}
}
async create<T extends ModelType>(cls: Class<T>, item: OptionalId<T>): Promise<T> {
try {
const clean = await ModelCrudUtil.preStore(cls, item, this);
const id = this.preUpdate(clean);
await this.client.index({
...this.manager.getIdentity(cls),
id,
refresh: true,
body: castTo<T & { id: never }>(clean)
});
return this.postUpdate(clean, id);
} catch (error) {
console.error(error);
throw error;
}
}
async update<T extends ModelType>(cls: Class<T>, item: T): Promise<T> {
ModelCrudUtil.ensureNotSubType(cls);
item = await ModelCrudUtil.preStore(cls, item, this);
const id = this.preUpdate(item);
if (ModelRegistryIndex.getConfig(cls).expiresAt) {
await this.get(cls, id);
}
await this.client.index({
...this.manager.getIdentity(cls),
id,
op_type: 'index',
refresh: true,
body: castTo<T & { id: never }>(item)
});
return this.postUpdate(item, id);
}
async upsert<T extends ModelType>(cls: Class<T>, input: OptionalId<T>): Promise<T> {
ModelCrudUtil.ensureNotSubType(cls);
const item = await ModelCrudUtil.preStore(cls, input, this);
const id = this.preUpdate(item);
await this.client.update({
...this.manager.getIdentity(cls),
id,
refresh: true,
doc: item,
doc_as_upsert: true
});
return this.postUpdate(item, id);
}
async updatePartial<T extends ModelType>(cls: Class<T>, data: Partial<T> & { id: string }, view?: string): Promise<T> {
ModelCrudUtil.ensureNotSubType(cls);
const id = data.id;
const item = castTo<typeof data>(await ModelCrudUtil.prePartialUpdate(cls, data, view));
const script = ElasticsearchSchemaUtil.generateUpdateScript(item);
try {
await this.client.update({
...this.manager.getIdentity(cls),
id,
refresh: true,
script,
});
} catch (error) {
if (error instanceof Error && /document_missing_exception/.test(error.message)) {
throw new NotFoundError(cls, id);
}
throw error;
}
return this.get(cls, id);
}
async * list<T extends ModelType>(cls: Class<T>): AsyncIterable<T> {
let search: estypes.SearchResponse<T> = await this.execSearch<T>(cls, {
scroll: '2m',
size: 100,
query: ElasticsearchQueryUtil.getSearchQuery(cls, {})
});
while (search.hits.hits.length > 0) {
for (const hit of search.hits.hits) {
try {
yield this.postLoad(cls, hit);
} catch (error) {
if (!(error instanceof NotFoundError)) {
throw error;
}
}
search = await this.client.scroll({
scroll_id: search._scroll_id,
scroll: '2m'
});
}
}
}
async processBulk<T extends ModelType>(cls: Class<T>, operations: BulkOperation<T>[]): Promise<BulkResponse<EsBulkError>> {
await ModelBulkUtil.preStore(cls, operations, this);
type BulkDoc = Partial<Record<'delete' | 'create' | 'index' | 'update', { _index: string, _id?: string }>>;
const body = operations.reduce<(T | BulkDoc | { doc: T })[]>((toRun, operation) => {
const core = (operation.upsert ?? operation.delete ?? operation.insert ?? operation.update ?? { constructor: cls });
const { index } = this.manager.getIdentity(asConstructable<T>(core).constructor);
const identity: { _index: string, _type?: unknown } = { _index: index };
if (operation.delete) {
toRun.push({ delete: { ...identity, _id: operation.delete.id } });
} else if (operation.insert) {
const id = this.preUpdate(operation.insert);
toRun.push({ create: { ...identity, _id: id } }, castTo(operation.insert));
} else if (operation.upsert) {
const id = this.preUpdate(operation.upsert);
toRun.push({ index: { ...identity, _id: id } }, castTo(operation.upsert));
} else if (operation.update) {
const id = this.preUpdate(operation.update);
toRun.push({ update: { ...identity, _id: id } }, { doc: operation.update });
}
return toRun;
}, []);
const result = await this.client.bulk({
operations: body,
refresh: true
});
const out: BulkResponse<EsBulkError> = {
counts: {
delete: 0,
insert: 0,
upsert: 0,
update: 0,
error: 0
},
insertedIds: new Map(),
errors: []
};
type CountProperty = keyof typeof out['counts'];
for (let i = 0; i < result.items.length; i++) {
const item = result.items[i];
const [key] = TypedObject.keys(item);
const responseItem = item[key]!;
if (responseItem.error) {
out.errors.push({
reason: responseItem.error!.reason!,
type: responseItem.error!.type
});
out.counts.error += 1;
} else {
let property: CountProperty;
switch (key) {
case 'create': property = 'insert'; break;
case 'index': property = operations[i].insert ? 'insert' : 'upsert'; break;
case 'delete': case 'update': property = key; break;
default: {
throw new Error(`Unknown response key: ${key}`);
}
}
if (responseItem.result === 'created') {
out.insertedIds.set(i, responseItem._id!);
(operations[i].insert ?? operations[i].upsert)!.id = responseItem._id!;
}
out.counts[property] += 1;
}
}
return out;
}
// Expiry
deleteExpired<T extends ModelType>(cls: Class<T>): Promise<number> {
return ModelQueryCrudUtil.deleteExpired(this, cls);
}
// Indexed
async getByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: DeepPartial<T>): Promise<T> {
const { key } = ModelIndexedUtil.computeIndexKey(cls, idx, body);
const result = await this.execSearch<T>(cls, {
query: ElasticsearchQueryUtil.getSearchQuery(cls,
ElasticsearchQueryUtil.extractWhereTermQuery(cls,
ModelIndexedUtil.projectIndex(cls, idx, body))
)
});
if (!result.hits.hits.length) {
throw new NotFoundError(`${cls.name}: ${idx}`, key);
}
return this.postLoad(cls, result.hits.hits[0]);
}
async deleteByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: DeepPartial<T>): Promise<void> {
const { key } = ModelIndexedUtil.computeIndexKey(cls, idx, body);
const result = await this.client.deleteByQuery({
index: this.manager.getIdentity(cls).index,
query: ElasticsearchQueryUtil.getSearchQuery(cls,
ElasticsearchQueryUtil.extractWhereTermQuery(cls,
ModelIndexedUtil.projectIndex(cls, idx, body))
),
refresh: true
});
if (result.deleted) {
return;
}
throw new NotFoundError(`${cls.name}: ${idx}`, key);
}
async upsertByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: OptionalId<T>): Promise<T> {
return ModelIndexedUtil.naiveUpsert(this, cls, idx, body);
}
async * listByIndex<T extends ModelType>(cls: Class<T>, idx: string, body?: DeepPartial<T>): AsyncIterable<T> {
const config = ModelRegistryIndex.getIndex(cls, idx, ['sorted', 'unsorted']);
let search = await this.execSearch<T>(cls, {
scroll: '2m',
size: 100,
query: ElasticsearchQueryUtil.getSearchQuery(cls,
ElasticsearchQueryUtil.extractWhereTermQuery(cls,
ModelIndexedUtil.projectIndex(cls, idx, body, { emptySortValue: { $exists: true } }))
),
sort: ElasticsearchQueryUtil.getSort(config.fields)
});
while (search.hits.hits.length > 0) {
for (const hit of search.hits.hits) {
try {
yield this.postLoad(cls, hit);
} catch (error) {
if (!(error instanceof NotFoundError)) {
throw error;
}
}
search = await this.client.scroll({
scroll_id: search._scroll_id,
scroll: '2m'
});
}
}
}
// Query
async query<T extends ModelType>(cls: Class<T>, query: PageableModelQuery<T>): Promise<T[]> {
await QueryVerifier.verify(cls, query);
const search = ElasticsearchQueryUtil.getSearchObject(cls, query, this.config.schemaConfig);
const results = await this.execSearch(cls, search);
const shouldRemoveIds = query.select && 'id' in query.select && !query.select.id;
return Promise.all(results.hits.hits.map(hit => this.postLoad(cls, hit).then(item => {
if (shouldRemoveIds) {
delete castTo<OptionalId<T>>(item).id;
}
return item;
})));
}
async queryOne<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>, failOnMany: boolean = true): Promise<T> {
const result = await this.query<T>(cls, { ...query, limit: failOnMany ? 2 : 1 });
return ModelQueryUtil.verifyGetSingleCounts<T>(cls, failOnMany, result, query.where);
}
async queryCount<T extends ModelType>(cls: Class<T>, query: Query<T>): Promise<number> {
await QueryVerifier.verify(cls, query);
const search = ElasticsearchQueryUtil.getSearchObject(cls, { ...query, limit: 0 }, this.config.schemaConfig);
const result: number | { value: number } = (await this.execSearch(cls, search)).hits.total || { value: 0 };
return typeof result !== 'number' ? result.value : result;
}
// Query Crud
async updateByQuery<T extends ModelType>(cls: Class<T>, data: T, query: ModelQuery<T>): Promise<T> {
ModelCrudUtil.ensureNotSubType(cls);
await QueryVerifier.verify(cls, query);
const item = await ModelCrudUtil.preStore(cls, data, this);
const id = this.preUpdate(item);
const where = ModelQueryUtil.getWhereClause(cls, query.where);
if (id) {
where.id = id;
}
query.where = where;
if (ModelRegistryIndex.getConfig(cls).expiresAt) {
await this.get(cls, id);
}
const search = ElasticsearchQueryUtil.getSearchObject(cls, query, this.config.schemaConfig);
const copy = BindUtil.bindSchemaToObject(cls, asFull<T>({}), item);
try {
const result = await this.client.updateByQuery({
...this.manager.getIdentity(cls),
refresh: true,
query: search.query,
max_docs: 1,
script: ElasticsearchSchemaUtil.generateReplaceScript(castTo(copy))
});
if (result.version_conflicts || result.updated === undefined || result.updated === 0) {
throw new NotFoundError(cls, id);
}
} catch (error) {
if (error instanceof errors.ResponseError && 'version_conflicts' in error.body) {
throw new NotFoundError(cls, id);
} else {
throw error;
}
}
return this.postUpdate(item, id);
}
async deleteByQuery<T extends ModelType>(cls: Class<T>, query: ModelQuery<T> = {}): Promise<number> {
await QueryVerifier.verify(cls, query);
const { sort: _, ...rest } = ElasticsearchQueryUtil.getSearchObject(cls, query, this.config.schemaConfig, false);
const result = await this.client.deleteByQuery({
...this.manager.getIdentity(cls),
...rest,
refresh: true,
});
return result.deleted ?? 0;
}
async updatePartialByQuery<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>, data: Partial<T>): Promise<number> {
await QueryVerifier.verify(cls, query);
const item = await ModelCrudUtil.prePartialUpdate(cls, data);
const script = ElasticsearchSchemaUtil.generateUpdateScript(item);
const search = ElasticsearchQueryUtil.getSearchObject(cls, query, this.config.schemaConfig);
const result = await this.client.updateByQuery({
...this.manager.getIdentity(cls),
refresh: true,
query: search.query,
script,
});
return result.updated ?? 0;
}
// Query Facet
async suggest<T extends ModelType>(cls: Class<T>, field: ValidStringFields<T>, prefix?: string, query?: PageableModelQuery<T>): Promise<T[]> {
await QueryVerifier.verify(cls, query);
const resolvedQuery = ModelQuerySuggestUtil.getSuggestQuery<T>(cls, field, prefix, query);
const search = ElasticsearchQueryUtil.getSearchObject(cls, resolvedQuery);
const result = await this.execSearch(cls, search);
const all = await Promise.all(result.hits.hits.map(hit => this.postLoad(cls, hit)));
return ModelQuerySuggestUtil.combineSuggestResults(cls, field, prefix, all, (_, value) => value, query && query.limit);
}
async suggestValues<T extends ModelType>(cls: Class<T>, field: ValidStringFields<T>, prefix?: string, query?: PageableModelQuery<T>): Promise<string[]> {
await QueryVerifier.verify(cls, query);
const resolvedQuery = ModelQuerySuggestUtil.getSuggestQuery<T>(cls, field, prefix, {
select: castTo({ [field]: 1 }),
...query
});
const search = ElasticsearchQueryUtil.getSearchObject(cls, resolvedQuery);
const result = await this.execSearch(cls, search);
const all = await Promise.all(result.hits.hits.map(hit => castTo<T>(({ [field]: field === 'id' ? hit._id : hit._source![field] }))));
return ModelQuerySuggestUtil.combineSuggestResults(cls, field, prefix, all, item => item, query && query.limit);
}
// Facet
async facet<T extends ModelType>(cls: Class<T>, field: ValidStringFields<T>, query?: ModelQuery<T>): Promise<ModelQueryFacet[]> {
await QueryVerifier.verify(cls, query);
const resolvedSearch = ElasticsearchQueryUtil.getSearchObject(cls, query ?? {}, this.config.schemaConfig);
const search: estypes.SearchRequest = {
query: resolvedSearch.query ?? { ['match_all']: {} },
aggs: { [field]: { terms: { field, size: 100 } } },
size: 0
};
const result = await this.execSearch(cls, search);
const { buckets } = castTo<estypes.AggregationsStringTermsAggregate>('buckets' in result.aggregations![field] ? result.aggregations![field] : { buckets: [] });
const out = Array.isArray(buckets) ? buckets.map(b => ({ key: b.key!.toString(), count: b.doc_count })) : [];
return out;
}
}