@travetto/model-mongo
Version:
Mongo backing for the travetto model module.
598 lines (503 loc) • 21.4 kB
text/typescript
import { pipeline } from 'node:stream/promises';
import {
type Db, GridFSBucket, MongoClient, type GridFSFile, type Collection,
type ObjectId, type Binary, type RootFilterOperators, type Filter,
type WithId as MongoWithId,
} from 'mongodb';
import {
ModelRegistryIndex, type ModelType, type OptionalId, type ModelCrudSupport, type ModelStorageSupport,
type ModelExpirySupport, type ModelBulkSupport, type ModelIndexedSupport, type BulkOperation, type BulkResponse,
NotFoundError, ExistsError, type ModelBlobSupport,
ModelCrudUtil, ModelIndexedUtil, ModelStorageUtil, ModelExpiryUtil, ModelBulkUtil, ModelBlobUtil
} from '@travetto/model';
import {
type ModelQuery, type ModelQueryCrudSupport, type ModelQueryFacetSupport, type ModelQuerySupport,
type PageableModelQuery, type ValidStringFields, type WhereClause, type ModelQuerySuggestSupport,
QueryVerifier, ModelQueryUtil, ModelQuerySuggestUtil, ModelQueryCrudUtil,
type ModelQueryFacet,
} from '@travetto/model-query';
import {
ShutdownManager, type Class, type DeepPartial, TypedObject,
castTo, asFull, type BlobMeta, type ByteRange, type BinaryInput, BinaryUtil
} from '@travetto/runtime';
import { Injectable } from '@travetto/di';
import { MongoUtil, type PlainIdx, type WithId } from './internal/util.ts';
import type { MongoModelConfig } from './config.ts';
const ListIndexSymbol = Symbol();
type BlobRaw = GridFSFile & { metadata?: BlobMeta };
type MongoTextSearch = RootFilterOperators<unknown>['$text'];
export const ModelBlobNamespace = '__blobs';
/**
* Mongo-based model source
*/
export class MongoModelService implements
ModelCrudSupport, ModelStorageSupport,
ModelBulkSupport, ModelBlobSupport,
ModelIndexedSupport, ModelQuerySupport,
ModelQueryCrudSupport, ModelQueryFacetSupport,
ModelQuerySuggestSupport, ModelExpirySupport {
#db: Db;
#bucket: GridFSBucket;
idSource = ModelCrudUtil.uuidSource();
client: MongoClient;
config: MongoModelConfig;
constructor(config: MongoModelConfig) { this.config = config; }
restoreId(item: { id?: string, _id?: unknown }): void {
if (item._id) {
item.id ??= MongoUtil.idToString(castTo(item._id));
delete item._id;
}
}
async postLoad<T extends ModelType>(cls: Class<T>, item: T | MongoWithId<T>): Promise<T> {
this.restoreId(item);
return await ModelCrudUtil.load(cls, item);
}
postUpdate<T extends ModelType>(item: T, id?: string): T {
if (id) {
item.id ??= id;
}
this.restoreId(item);
return item;
}
preUpdate<T extends OptionalId<ModelType>>(item: T & { _id?: Binary, id: string }): string;
preUpdate<T extends OptionalId<ModelType>>(item: Omit<T, 'id'> & { _id?: Binary }): undefined;
preUpdate<T extends OptionalId<ModelType>>(item: T & { _id?: Binary, id: undefined }): undefined;
preUpdate<T extends OptionalId<ModelType>>(item: T & { _id?: Binary, id?: string }): string | undefined {
if (item && item.id) {
const id = item.id;
item._id = MongoUtil.uuid(id);
if (!this.config.storeId) {
delete item.id;
}
return id;
}
}
async #describeBlobRaw(location: string): Promise<BlobRaw> {
const files: BlobRaw[] = await this.#bucket.find({ filename: location }, { limit: 1 }).toArray();
if (!files?.length) {
throw new NotFoundError(ModelBlobNamespace, location);
}
return files[0];
}
async postConstruct(): Promise<void> {
this.client = await MongoClient.connect(this.config.url, this.config.connectionOptions);
this.#db = this.client.db(this.config.namespace);
this.#bucket = new GridFSBucket(this.#db, {
bucketName: ModelBlobNamespace,
writeConcern: { w: 1 }
});
await ModelStorageUtil.storageInitialization(this);
ShutdownManager.signal.addEventListener('abort', () => this.client.close());
ModelExpiryUtil.registerCull(this);
}
getWhereFilter<T extends ModelType>(cls: Class<T>, where: WhereClause<T>, checkExpiry = true): Filter<T> {
return castTo(MongoUtil.extractWhereFilter(cls, where, checkExpiry));
}
getIdFilter<T extends ModelType>(cls: Class<T>, id: string, checkExpiry = true): Filter<T> {
return this.getWhereFilter(cls, castTo({ _id: MongoUtil.uuid(id) }), checkExpiry);
}
// Storage
async createStorage(): Promise<void> { }
async deleteStorage(): Promise<void> {
await this.#db.dropDatabase();
}
async upsertModel(cls: Class): Promise<void> {
const col = await this.getStore(cls);
const indices = MongoUtil.getIndices(cls, ModelRegistryIndex.getConfig(cls).indices);
const existingIndices = (await col.indexes().catch(() => [])).filter(idx => idx.name !== '_id_');
const pendingMap = Object.fromEntries(indices.map(pair => [pair[1].name!, pair]));
const existingMap = Object.fromEntries(existingIndices.map(idx => [idx.name!, idx.key]));
for (const idx of existingIndices) {
if (!idx.name) {
continue;
}
const pending = pendingMap[idx.name];
if (!pending) {
console.debug('Deleting index', { indices: idx.name });
await col.dropIndex(idx.name);
} else if (MongoUtil.isIndexChanged(idx, pending)) {
console.debug('Updating index', { indices: idx.name });
await col.dropIndex(idx.name);
await col.createIndex(...pending);
}
}
for (const [name, idx] of Object.entries(pendingMap)) {
if (!existingMap[name]) {
console.debug('Creating index', { indices: name });
await col.createIndex(...idx);
}
}
}
async truncateModel<T extends ModelType>(cls: Class<T>): Promise<void> {
const col = await this.getStore(cls);
await col.deleteMany({});
}
async truncateBlob(): Promise<void> {
await this.#bucket.drop().catch(() => { });
}
/**
* Get mongo collection
*/
async getStore<T extends ModelType>(cls: Class<T>): Promise<Collection<T>> {
return this.#db.collection(ModelRegistryIndex.getStoreName(cls));
}
// Crud
async get<T extends ModelType>(cls: Class<T>, id: string): Promise<T> {
const store = await this.getStore(cls);
const result = await store.findOne(this.getIdFilter(cls, id), {});
if (result) {
const finalized = await this.postLoad(cls, result);
if (finalized) {
return finalized;
}
}
throw new NotFoundError(cls, id);
}
async create<T extends ModelType>(cls: Class<T>, item: OptionalId<T>): Promise<T> {
const cleaned = await ModelCrudUtil.preStore<WithId<T, Binary>>(cls, item, this);
const id = this.preUpdate(cleaned);
const store = await this.getStore(cls);
const result = await store.insertOne(castTo(cleaned));
if (!result.insertedId) {
throw new ExistsError(cls, id);
}
return this.postUpdate(cleaned, id);
}
async update<T extends ModelType>(cls: Class<T>, item: T): Promise<T> {
item = await ModelCrudUtil.preStore(cls, item, this);
const id = this.preUpdate(item);
const store = await this.getStore(cls);
const result = await store.replaceOne(this.getIdFilter(cls, id), item);
if (result.matchedCount === 0) {
throw new NotFoundError(cls, id);
}
return this.postUpdate(item, id);
}
async upsert<T extends ModelType>(cls: Class<T>, item: OptionalId<T>): Promise<T> {
const cleaned = await ModelCrudUtil.preStore<WithId<T, Binary>>(cls, item, this);
const id = this.preUpdate(cleaned);
const store = await this.getStore(cls);
try {
await store.updateOne(
this.getIdFilter(cls, id, false),
{ $set: cleaned },
{ upsert: true }
);
} catch (error) {
if (error instanceof Error && error.message.includes('duplicate key error')) {
throw new ExistsError(cls, id);
} else {
throw error;
}
}
return this.postUpdate(cleaned, id);
}
async updatePartial<T extends ModelType>(cls: Class<T>, item: Partial<T> & { id: string }, view?: string): Promise<T> {
const store = await this.getStore(cls);
const final = await ModelCrudUtil.prePartialUpdate(cls, item, view);
const simple = MongoUtil.extractSimple(cls, final, undefined, false);
const operation: Partial<T> = castTo(Object
.entries(simple)
.reduce<Partial<Record<'$unset' | '$set', Record<string, unknown>>>>((document, [key, value]) => {
if (value === null || value === undefined) {
(document.$unset ??= {})[key] = value;
} else {
(document.$set ??= {})[key] = value;
}
return document;
}, {}));
const id = item.id;
const result = await store.findOneAndUpdate(
this.getIdFilter(cls, id),
operation,
{ returnDocument: 'after', includeResultMetadata: true }
);
if (!result.value) {
throw new NotFoundError(cls, id);
}
return this.get(cls, id);
}
async delete<T extends ModelType>(cls: Class<T>, id: string): Promise<void> {
const store = await this.getStore(cls);
const result = await store.deleteOne(this.getIdFilter(cls, id, false));
if (result.deletedCount === 0) {
throw new NotFoundError(cls, id);
}
}
async * list<T extends ModelType>(cls: Class<T>): AsyncIterable<T> {
const store = await this.getStore(cls);
const cursor = store.find(this.getWhereFilter(cls, {}), { timeout: true }).batchSize(100);
for await (const item of cursor) {
try {
yield await this.postLoad(cls, item);
} catch (error) {
if (!(error instanceof NotFoundError)) {
throw error;
}
}
}
}
// Blob
async upsertBlob(location: string, input: BinaryInput, meta?: BlobMeta, overwrite = true): Promise<void> {
const existing = await this.getBlobMeta(location).then(() => true, () => false);
if (!overwrite && existing) {
return;
}
const [stream, blobMeta] = await ModelBlobUtil.getInput(input, meta);
const writeStream = this.#bucket.openUploadStream(location, { metadata: blobMeta });
await pipeline(stream, writeStream);
if (existing) {
const [read] = await this.#bucket.find({ filename: location, _id: { $ne: writeStream.id } }).toArray();
await this.#bucket.delete(read._id);
}
}
async getBlob(location: string, range?: ByteRange): Promise<Blob> {
const meta = await this.getBlobMeta(location);
const final = range ? ModelBlobUtil.enforceRange(range, meta.size!) : undefined;
const mongoRange = final ? { start: final.start, end: final.end + 1 } : undefined;
return BinaryUtil.readableBlob(() => this.#bucket.openDownloadStreamByName(location, mongoRange), { ...meta, range: final });
}
async getBlobMeta(location: string): Promise<BlobMeta> {
const result = await this.#db.collection<{ metadata: BlobMeta }>(`${ModelBlobNamespace}.files`).findOne({ filename: location });
return result!.metadata;
}
async deleteBlob(location: string): Promise<void> {
const fileId = (await this.#describeBlobRaw(location))._id;
await this.#bucket.delete(fileId);
}
async updateBlobMeta(location: string, meta: BlobMeta): Promise<void> {
await this.#db.collection<{ metadata: BlobMeta }>(`${ModelBlobNamespace}.files`).findOneAndUpdate(
{ filename: location },
{ $set: { metadata: meta, contentType: meta.contentType! } },
);
}
// Bulk
async processBulk<T extends ModelType>(cls: Class<T>, operations: BulkOperation<T>[]): Promise<BulkResponse<{ index: number }>> {
const out: BulkResponse<{ index: number }> = {
errors: [],
counts: {
delete: 0,
update: 0,
upsert: 0,
insert: 0,
error: 0
},
insertedIds: new Map()
};
if (operations.length === 0) {
return out;
}
const store = await this.getStore<Partial<T> & ModelType>(cls);
const bulk = store.initializeUnorderedBulkOp({ writeConcern: { w: 1 } });
const { upsertedIds, insertedIds } = await ModelBulkUtil.preStore(cls, operations, this);
out.insertedIds = new Map([...upsertedIds.entries(), ...insertedIds.entries()]);
for (const operation of operations) {
if (operation.insert) {
this.preUpdate(operation.insert);
bulk.insert(operation.insert);
} else if (operation.upsert) {
const id = this.preUpdate(operation.upsert);
bulk.find({ _id: MongoUtil.uuid(id!) }).upsert().updateOne({ $set: operation.upsert });
} else if (operation.update) {
const id = this.preUpdate(operation.update);
bulk.find({ _id: MongoUtil.uuid(id) }).update({ $set: operation.update });
} else if (operation.delete) {
bulk.find({ _id: MongoUtil.uuid(operation.delete.id) }).deleteOne();
}
}
const result = await bulk.execute({});
// Restore all ids
for (const operation of operations) {
const core = operation.insert ?? operation.upsert ?? operation.update;
if (core) {
this.postUpdate(asFull(core));
}
}
for (const [index, _id] of TypedObject.entries<Record<string, string>>(result.upsertedIds)) {
out.insertedIds.set(+index, MongoUtil.idToString(_id));
}
if (out.counts) {
out.counts.delete = result.deletedCount;
out.counts.update = operations.filter(item => item.update).length;
out.counts.insert = result.insertedCount;
out.counts.upsert = operations.filter(item => item.upsert).length;
}
if (result.hasWriteErrors()) {
out.errors = result.getWriteErrors();
for (const error of out.errors) {
const operation = operations[error.index];
const key = TypedObject.keys(operation)[0];
out.counts[key] -= 1;
}
out.counts.error = out.errors.length;
}
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 store = await this.getStore(cls);
const result = await store.findOne(
this.getWhereFilter(
cls,
castTo(ModelIndexedUtil.projectIndex(cls, idx, body))
)
);
if (!result) {
throw new NotFoundError(`${cls.name}: ${idx}`, key);
}
return await this.postLoad(cls, result);
}
async deleteByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: DeepPartial<T>): Promise<void> {
const { key } = ModelIndexedUtil.computeIndexKey(cls, idx, body);
const store = await this.getStore(cls);
const result = await store.deleteOne(
this.getWhereFilter(
cls,
castTo(ModelIndexedUtil.projectIndex(cls, idx, body))
)
);
if (result.deletedCount) {
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 store = await this.getStore(cls);
const idxConfig = ModelRegistryIndex.getIndex(cls, idx, ['sorted', 'unsorted']);
const where = this.getWhereFilter(
cls,
castTo(ModelIndexedUtil.projectIndex(cls, idx, body, { emptySortValue: { $exists: true } }))
);
const sort = castTo<{ [ListIndexSymbol]: PlainIdx }>(idxConfig)[ListIndexSymbol] ??= MongoUtil.getPlainIndex(idxConfig);
const cursor = store.find(where, { timeout: true }).batchSize(100).sort(castTo(sort));
for await (const item of cursor) {
yield await this.postLoad(cls, item);
}
}
// Query
async query<T extends ModelType>(cls: Class<T>, query: PageableModelQuery<T>): Promise<T[]> {
await QueryVerifier.verify(cls, query);
const col = await this.getStore(cls);
const filter = MongoUtil.extractWhereFilter(cls, query.where);
const cursor = col.find(filter, {});
const items = await MongoUtil.prepareCursor(cls, cursor, query).toArray();
return await Promise.all(items.map(item => this.postLoad(cls, item)));
}
async queryCount<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>): Promise<number> {
await QueryVerifier.verify(cls, query);
const col = await this.getStore(cls);
const filter = MongoUtil.extractWhereFilter(cls, query.where);
return col.countDocuments(filter);
}
async queryOne<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>, failOnMany = true): Promise<T> {
const results = await this.query<T>(cls, { ...query, limit: failOnMany ? 2 : 1 });
return ModelQueryUtil.verifyGetSingleCounts<T>(cls, failOnMany, results, query.where);
}
// Query Crud
async updateByQuery<T extends ModelType>(cls: Class<T>, data: T, query: ModelQuery<T>): Promise<T> {
await QueryVerifier.verify(cls, query);
const col = await this.getStore(cls);
const item = await ModelCrudUtil.preStore(cls, data, this);
const id = this.preUpdate(item);
const where = ModelQueryUtil.getWhereClause(cls, query.where);
where.id = id;
const filter = MongoUtil.extractWhereFilter(cls, where);
const result = await col.replaceOne(filter, item);
if (result.matchedCount === 0) {
throw new NotFoundError(cls, id);
}
return this.postUpdate(item, id);
}
async deleteByQuery<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>): Promise<number> {
await QueryVerifier.verify(cls, query);
const col = await this.getStore(cls);
const filter = MongoUtil.extractWhereFilter(cls, query.where, false);
const result = await col.deleteMany(filter);
return result.deletedCount ?? 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 col = await this.getStore(cls);
const items = MongoUtil.extractSimple(cls, item);
const final = Object.entries(items).reduce<Partial<Record<'$unset' | '$set', Record<string, unknown>>>>(
(document, [key, value]) => {
if (value === null || value === undefined) {
(document.$unset ??= {})[key] = value;
} else {
(document.$set ??= {})[key] = value;
}
return document;
}, {});
const filter = MongoUtil.extractWhereFilter(cls, query.where);
const result = await col.updateMany(filter, castTo(final));
return result.matchedCount;
}
// Facet
async facet<T extends ModelType>(cls: Class<T>, field: ValidStringFields<T>, query?: ModelQuery<T>): Promise<ModelQueryFacet[]> {
await QueryVerifier.verify(cls, query);
const col = await this.getStore(cls);
if (query) {
await QueryVerifier.verify(cls, query);
}
let queryObject: Record<string, unknown> = { [field]: { $exists: true } };
if (query?.where) {
queryObject = { $and: [queryObject, MongoUtil.extractWhereFilter(cls, query.where)] };
}
const aggregations: object[] = [
{ $match: queryObject },
{
$group: {
_id: `$${field}`,
count: {
$sum: 1
}
}
}
];
const result = await col.aggregate<{ _id: ObjectId, count: number }>(aggregations).toArray();
return result
.map(item => ({
key: MongoUtil.idToString(item._id),
count: item.count
}))
.toSorted((a, b) => b.count - a.count);
}
// Suggest
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.getSuggestFieldQuery<T>(cls, field, prefix, query);
const results = await this.query<T>(cls, resolvedQuery);
return ModelQuerySuggestUtil.combineSuggestResults<T, string>(cls, field, prefix, results, (a) => a, query && query.limit);
}
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 results = await this.query<T>(cls, resolvedQuery);
return ModelQuerySuggestUtil.combineSuggestResults(cls, field, prefix, results, (_, b) => b, query && query.limit);
}
// Other
async queryText<T extends ModelType>(cls: Class<T>, search: string | MongoTextSearch, query: PageableModelQuery<T> = {}): Promise<T[]> {
await QueryVerifier.verify(cls, query);
const col = await this.getStore(cls);
const filter = MongoUtil.extractWhereFilter(cls, query.where);
if (typeof search === 'string') {
search = { $search: search, $language: 'en' };
}
(query.sort ??= []).unshift(castTo<(typeof query.sort[0])>({
score: { $meta: 'textScore' }
}));
const cursor = col.find(castTo({ $and: [{ $text: search }, filter] }), {});
const items = await MongoUtil.prepareCursor(cls, cursor, query).toArray();
return await Promise.all(items.map(item => this.postLoad(cls, item)));
}
}