@travetto/model-mongo
Version:
Mongo backing for the travetto model module.
590 lines (495 loc) • 20.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 {
ModelRegistry, ModelType, OptionalId, ModelCrudSupport, ModelStorageSupport,
ModelExpirySupport, ModelBulkSupport, ModelIndexedSupport, BulkOp, BulkResponse,
NotFoundError, ExistsError, ModelBlobSupport,
ModelCrudUtil, ModelIndexedUtil, ModelStorageUtil, ModelExpiryUtil, ModelBulkUtil, ModelBlobUtil,
} from '@travetto/model';
import {
ModelQuery, ModelQueryCrudSupport, ModelQueryFacetSupport, ModelQuerySupport,
PageableModelQuery, ValidStringFields, WhereClause, ModelQuerySuggestSupport,
QueryVerifier, ModelQueryUtil, ModelQuerySuggestUtil, ModelQueryCrudUtil,
ModelQueryFacet,
} from '@travetto/model-query';
import {
ShutdownManager, type Class, type DeepPartial, TypedObject,
castTo, asFull, BlobMeta, ByteRange, BinaryInput, BinaryUtil
} from '@travetto/runtime';
import { Injectable } from '@travetto/di';
import { MongoUtil, PlainIdx, WithId } from './internal/util.ts';
import { 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.options);
this.#db = this.client.db(this.config.namespace);
this.#bucket = new GridFSBucket(this.#db, {
bucketName: ModelBlobNamespace,
writeConcern: { w: 1 }
});
await ModelStorageUtil.registerModelChangeListener(this);
ShutdownManager.onGracefulShutdown(() => this.client.close(), this);
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 establishIndices<T extends ModelType>(cls: Class<T>): Promise<void> {
const col = await this.getStore(cls);
const creating = MongoUtil.getIndices(cls, ModelRegistry.get(cls).indices);
if (creating.length) {
console.debug('Creating indexes', { indices: creating });
for (const el of creating) {
await col.createIndex(...el);
}
}
}
async createModel(cls: Class): Promise<void> {
await this.establishIndices(cls);
}
async changeModel(cls: Class): Promise<void> {
await this.establishIndices(cls);
}
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(ModelRegistry.getStore(cls).toLowerCase().replace(/[^A-Za-z0-9_]+/g, '_'));
}
// 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 (err) {
if (err instanceof Error && err.message.includes('duplicate key error')) {
throw new ExistsError(cls, id);
} else {
throw err;
}
}
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>>>>((acc, [k, v]) => {
if (v === null || v === undefined) {
(acc.$unset ??= {})[k] = v;
} else {
(acc.$set ??= {})[k] = v;
}
return acc;
}, {}));
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 el of cursor) {
try {
yield await this.postLoad(cls, el);
} catch (err) {
if (!(err instanceof NotFoundError)) {
throw err;
}
}
}
}
// 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, {
contentType: blobMeta.contentType,
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: BulkOp<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 op of operations) {
if (op.insert) {
this.preUpdate(op.insert);
bulk.insert(op.insert);
} else if (op.upsert) {
const id = this.preUpdate(op.upsert);
bulk.find({ _id: MongoUtil.uuid(id!) }).upsert().updateOne({ $set: op.upsert });
} else if (op.update) {
const id = this.preUpdate(op.update);
bulk.find({ _id: MongoUtil.uuid(id) }).update({ $set: op.update });
} else if (op.delete) {
bulk.find({ _id: MongoUtil.uuid(op.delete.id) }).deleteOne();
}
}
const result = await bulk.execute({});
// Restore all ids
for (const op of operations) {
const core = op.insert ?? op.upsert ?? op.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(x => x.update).length;
out.counts.insert = result.insertedCount;
out.counts.upsert = operations.filter(x => x.upsert).length;
}
if (result.hasWriteErrors()) {
out.errors = result.getWriteErrors();
for (const err of out.errors) {
const op = operations[err.index];
const k = TypedObject.keys(op)[0];
out.counts[k] -= 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 idxCfg = ModelRegistry.getIndex(cls, idx, ['sorted', 'unsorted']);
const where = this.getWhereFilter(
cls,
castTo(ModelIndexedUtil.projectIndex(cls, idx, body, { emptySortValue: { $exists: true } }))
);
const sort = castTo<{ [ListIndexSymbol]: PlainIdx }>(idxCfg)[ListIndexSymbol] ??= MongoUtil.getPlainIndex(idxCfg);
const cursor = store.find(where, { timeout: true }).batchSize(100).sort(castTo(sort));
for await (const el of cursor) {
yield await this.postLoad(cls, el);
}
}
// 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(r => this.postLoad(cls, r)));
}
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>>>>(
(acc, [k, v]) => {
if (v === null || v === undefined) {
(acc.$unset ??= {})[k] = v;
} else {
(acc.$set ??= {})[k] = v;
}
return acc;
}, {});
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 q: Record<string, unknown> = { [field]: { $exists: true } };
if (query?.where) {
q = { $and: [q, MongoUtil.extractWhereFilter(cls, query.where)] };
}
const aggregations: object[] = [
{ $match: q },
{
$group: {
_id: `$${field}`,
count: {
$sum: 1
}
}
}
];
const result = await col.aggregate<{ _id: ObjectId, count: number }>(aggregations).toArray();
return result
.map(val => ({
key: MongoUtil.idToString(val._id),
count: val.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 q = ModelQuerySuggestUtil.getSuggestFieldQuery<T>(cls, field, prefix, query);
const results = await this.query<T>(cls, q);
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 q = ModelQuerySuggestUtil.getSuggestQuery<T>(cls, field, prefix, query);
const results = await this.query<T>(cls, q);
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(r => this.postLoad(cls, r)));
}
}