feat(release): 1.0.0
This commit is contained in:
25
src/Broadcast.ts
Normal file
25
src/Broadcast.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Document, WithId } from "./Types.js";
|
||||
|
||||
export const BroadcastChannel =
|
||||
globalThis.BroadcastChannel ??
|
||||
class BroadcastChannelMock {
|
||||
onmessage?: any;
|
||||
postMessage() {}
|
||||
close() {}
|
||||
};
|
||||
|
||||
export type StorageBroadcast<TSchema extends Document = Document> =
|
||||
| {
|
||||
name: string;
|
||||
type: "insertOne" | "updateOne";
|
||||
data: WithId<TSchema>;
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
type: "insertMany" | "updateMany" | "remove";
|
||||
data: WithId<TSchema>[];
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
type: "flush";
|
||||
};
|
||||
3
src/Clone.ts
Normal file
3
src/Clone.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import makeClone from "rfdc";
|
||||
|
||||
export const clone = makeClone();
|
||||
172
src/Collection.ts
Normal file
172
src/Collection.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { Observable, Subscription } from "rxjs";
|
||||
|
||||
import { observe, observeOne } from "./Observe/mod.js";
|
||||
import {
|
||||
ChangeEvent,
|
||||
InsertManyResult,
|
||||
InsertOneResult,
|
||||
Options,
|
||||
RemoveResult,
|
||||
Storage,
|
||||
UpdateResult
|
||||
} from "./Storage/mod.js";
|
||||
import { Document, Filter, UpdateFilter, WithId } from "./Types.js";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Collection
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export class Collection<TSchema extends Document = Document> {
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly storage: Storage<TSchema>
|
||||
) {}
|
||||
|
||||
get observable() {
|
||||
return this.storage.observable;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Mutators
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async insertOne(document: Partial<WithId<TSchema>>): Promise<InsertOneResult> {
|
||||
return this.storage.resolve().then((storage) => storage.insertOne(document));
|
||||
}
|
||||
|
||||
async insertMany(documents: Partial<WithId<TSchema>>[]): Promise<InsertManyResult> {
|
||||
return this.storage.resolve().then((storage) => storage.insertMany(documents));
|
||||
}
|
||||
|
||||
async updateOne(filter: Filter<WithId<TSchema>>, update: UpdateFilter<TSchema>): Promise<UpdateResult> {
|
||||
return this.storage.resolve().then((storage) => storage.updateOne(filter, update));
|
||||
}
|
||||
|
||||
async updateMany(filter: Filter<WithId<TSchema>>, update: UpdateFilter<TSchema>): Promise<UpdateResult> {
|
||||
return this.storage.resolve().then((storage) => storage.updateMany(filter, update));
|
||||
}
|
||||
|
||||
async replaceOne(filter: Filter<WithId<TSchema>>, document: TSchema): Promise<UpdateResult> {
|
||||
return this.storage.resolve().then((storage) => storage.replace(filter, document));
|
||||
}
|
||||
|
||||
async remove(filter: Filter<WithId<TSchema>>): Promise<RemoveResult> {
|
||||
return this.storage.resolve().then((storage) => storage.remove(filter));
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Observers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
subscribe(
|
||||
filter?: Filter<WithId<TSchema>>,
|
||||
options?: SubscribeToSingle,
|
||||
next?: (document: WithId<TSchema> | undefined) => void
|
||||
): Subscription;
|
||||
subscribe(
|
||||
filter?: Filter<WithId<TSchema>>,
|
||||
options?: SubscribeToMany,
|
||||
next?: (documents: WithId<TSchema>[], changed: WithId<TSchema>[], type: ChangeEvent["type"]) => void
|
||||
): Subscription;
|
||||
subscribe(filter: Filter<WithId<TSchema>> = {}, options?: Options, next?: (...args: any[]) => void): Subscription {
|
||||
if (options?.limit === 1) {
|
||||
return this.#observeOne(filter).subscribe({ next });
|
||||
}
|
||||
return this.#observe(filter, options).subscribe({
|
||||
next: (value: [WithId<TSchema>[], WithId<TSchema>[], ChangeEvent["type"]]) => next?.(...value)
|
||||
});
|
||||
}
|
||||
|
||||
#observe(
|
||||
filter: Filter<WithId<TSchema>> = {},
|
||||
options?: Options
|
||||
): Observable<[WithId<TSchema>[], WithId<TSchema>[], ChangeEvent["type"]]> {
|
||||
return new Observable<[WithId<TSchema>[], WithId<TSchema>[], ChangeEvent["type"]]>((subscriber) => {
|
||||
return observe(this as any, filter, options, (values, changed, type) =>
|
||||
subscriber.next([values, changed, type] as any)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#observeOne(filter: Filter<WithId<TSchema>> = {}): Observable<WithId<TSchema> | undefined> {
|
||||
return new Observable<WithId<TSchema> | undefined>((subscriber) => {
|
||||
return observeOne(this as any, filter, (values) => subscriber.next(values as any));
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Queries
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Retrieve a record by the document 'id' key.
|
||||
*/
|
||||
async findById(id: string): Promise<WithId<TSchema> | undefined> {
|
||||
return this.storage.resolve().then((storage) => storage.findById(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a mingo filter search over the collection data and returns
|
||||
* a single document if one was found matching the filter and options.
|
||||
*/
|
||||
async findOne(filter: Filter<WithId<TSchema>> = {}, options?: Options): Promise<WithId<TSchema> | undefined> {
|
||||
return this.find(filter, options).then(([document]) => document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a mingo filter search over the collection data and returns any
|
||||
* documents matching the provided filter and options.
|
||||
*/
|
||||
async find(filter: Filter<WithId<TSchema>> = {}, options?: Options): Promise<WithId<TSchema>[]> {
|
||||
return this.storage.resolve().then((storage) => storage.find(filter, options));
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a mingo filter search over the collection data and returns
|
||||
* the count of all documents found matching the filter and options.
|
||||
*/
|
||||
async count(filter?: Filter<WithId<TSchema>>): Promise<number> {
|
||||
return this.storage.resolve().then((storage) => storage.count(filter));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all documents from the storage instance.
|
||||
*/
|
||||
flush(): void {
|
||||
this.storage.resolve().then((storage) => {
|
||||
storage.broadcast("flush");
|
||||
storage.flush();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type SubscriptionOptions = {
|
||||
sort?: Options["sort"];
|
||||
skip?: Options["skip"];
|
||||
range?: Options["range"];
|
||||
offset?: Options["offset"];
|
||||
limit?: Options["limit"];
|
||||
index?: Options["index"];
|
||||
};
|
||||
|
||||
export type SubscribeToSingle = Options & {
|
||||
limit: 1;
|
||||
};
|
||||
|
||||
export type SubscribeToMany = Options & {
|
||||
limit?: number;
|
||||
};
|
||||
34
src/Databases/IndexedDb.Cache.ts
Normal file
34
src/Databases/IndexedDb.Cache.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { hashCodeQuery } from "../Hash.js";
|
||||
import { Options } from "../Storage/mod.js";
|
||||
import type { Document, Filter, WithId } from "../Types.js";
|
||||
|
||||
export class IndexedDbCache<TSchema extends Document = Document> {
|
||||
readonly #cache = new Map<number, string[]>();
|
||||
readonly #documents = new Map<string, WithId<TSchema>>();
|
||||
|
||||
hash(filter: Filter<WithId<TSchema>>, options: Options): number {
|
||||
return hashCodeQuery(filter, options);
|
||||
}
|
||||
|
||||
set(hashCode: number, documents: WithId<TSchema>[]) {
|
||||
this.#cache.set(
|
||||
hashCode,
|
||||
documents.map((document) => document.id)
|
||||
);
|
||||
for (const document of documents) {
|
||||
this.#documents.set(document.id, document);
|
||||
}
|
||||
}
|
||||
|
||||
get(hashCode: number): WithId<TSchema>[] | undefined {
|
||||
const ids = this.#cache.get(hashCode);
|
||||
if (ids !== undefined) {
|
||||
return ids.map((id) => this.#documents.get(id) as WithId<TSchema>);
|
||||
}
|
||||
}
|
||||
|
||||
flush() {
|
||||
this.#cache.clear();
|
||||
this.#documents.clear();
|
||||
}
|
||||
}
|
||||
392
src/Databases/IndexedDb.Storage.ts
Normal file
392
src/Databases/IndexedDb.Storage.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import type { IDBPDatabase } from "idb";
|
||||
import { Query } from "mingo";
|
||||
import type { AnyVal } from "mingo/types";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { DBLogger, InsertLog, QueryLog, RemoveLog, ReplaceLog, UpdateLog } from "../Logger.js";
|
||||
import {
|
||||
addOptions,
|
||||
DuplicateDocumentError,
|
||||
getInsertManyResult,
|
||||
getInsertOneResult,
|
||||
Index,
|
||||
InsertManyResult,
|
||||
InsertOneResult,
|
||||
Options,
|
||||
RemoveResult,
|
||||
Storage,
|
||||
update,
|
||||
UpdateResult
|
||||
} from "../Storage/mod.js";
|
||||
import { Document, Filter, UpdateFilter, WithId } from "../Types.js";
|
||||
import { IndexedDbCache } from "./IndexedDb.Cache.js";
|
||||
|
||||
const OBJECT_PROTOTYPE = Object.getPrototypeOf({}) as AnyVal;
|
||||
const OBJECT_TAG = "[object Object]";
|
||||
|
||||
export class IndexedDbStorage<TSchema extends Document = Document> extends Storage<TSchema> {
|
||||
readonly #cache = new IndexedDbCache<TSchema>();
|
||||
readonly #promise: Promise<IDBPDatabase>;
|
||||
|
||||
#db?: IDBPDatabase;
|
||||
|
||||
constructor(name: string, promise: Promise<IDBPDatabase>, readonly log: DBLogger) {
|
||||
super(name);
|
||||
this.#promise = promise;
|
||||
}
|
||||
|
||||
async resolve() {
|
||||
if (this.#db === undefined) {
|
||||
this.#db = await this.#promise;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
async has(id: string): Promise<boolean> {
|
||||
const document = await this.db.getFromIndex(this.name, "id", id);
|
||||
if (document !== undefined) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get db() {
|
||||
if (this.#db === undefined) {
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
return this.#db;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Insert
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async insertOne(data: Partial<WithId<TSchema>>): Promise<InsertOneResult> {
|
||||
const logger = new InsertLog(this.name);
|
||||
|
||||
const document = { ...data, id: data.id ?? nanoid() } as any;
|
||||
if (await this.has(document.id)) {
|
||||
throw new DuplicateDocumentError(document, this as any);
|
||||
}
|
||||
await this.db.transaction(this.name, "readwrite", { durability: "relaxed" }).store.add(document);
|
||||
|
||||
this.broadcast("insertOne", document);
|
||||
this.#cache.flush();
|
||||
|
||||
this.log(logger.result());
|
||||
|
||||
return getInsertOneResult(document);
|
||||
}
|
||||
|
||||
async insertMany(data: Partial<WithId<TSchema>>[]): Promise<InsertManyResult> {
|
||||
const logger = new InsertLog(this.name);
|
||||
|
||||
const documents: WithId<TSchema>[] = [];
|
||||
|
||||
const tx = this.db.transaction(this.name, "readwrite", { durability: "relaxed" });
|
||||
await Promise.all(
|
||||
data.map((data) => {
|
||||
const document = { ...data, id: data.id ?? nanoid() } as WithId<TSchema>;
|
||||
documents.push(document);
|
||||
return tx.store.add(document);
|
||||
})
|
||||
);
|
||||
await tx.done;
|
||||
|
||||
this.broadcast("insertMany", documents);
|
||||
this.#cache.flush();
|
||||
|
||||
this.log(logger.result());
|
||||
|
||||
return getInsertManyResult(documents);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Read
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async findById(id: string): Promise<WithId<TSchema> | undefined> {
|
||||
return this.db.getFromIndex(this.name, "id", id);
|
||||
}
|
||||
|
||||
async find(filter: Filter<WithId<TSchema>>, options: Options = {}): Promise<WithId<TSchema>[]> {
|
||||
const logger = new QueryLog(this.name, { filter, options });
|
||||
|
||||
const hashCode = this.#cache.hash(filter, options);
|
||||
const cached = this.#cache.get(hashCode);
|
||||
if (cached !== undefined) {
|
||||
this.log(logger.result({ cached: true }));
|
||||
return cached;
|
||||
}
|
||||
|
||||
const indexes = this.#resolveIndexes(filter);
|
||||
let cursor = new Query(filter).find(await this.#getAll({ ...options, ...indexes }));
|
||||
if (options !== undefined) {
|
||||
cursor = addOptions(cursor, options);
|
||||
}
|
||||
|
||||
const documents = cursor.all() as WithId<TSchema>[];
|
||||
this.#cache.set(this.#cache.hash(filter, options), documents);
|
||||
|
||||
this.log(logger.result());
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Prototype! Needs to cover more mongodb query cases and investigation around
|
||||
* nested indexing in indexeddb.
|
||||
*/
|
||||
#resolveIndexes(filter: any): { index?: { [key: string]: any } } {
|
||||
const indexNames = this.db.transaction(this.name, "readonly").store.indexNames;
|
||||
const index: { [key: string]: any } = {};
|
||||
for (const key in filter) {
|
||||
if (indexNames.contains(key) === true) {
|
||||
let val: any;
|
||||
if (isObject(filter[key]) === true) {
|
||||
if (filter[key]["$in"] !== undefined) {
|
||||
val = filter[key]["$in"];
|
||||
}
|
||||
} else {
|
||||
val = filter[key];
|
||||
}
|
||||
if (val !== undefined) {
|
||||
index[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(index).length > 0) {
|
||||
return { index };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async #getAll({ index, offset, range, limit }: Options) {
|
||||
if (index !== undefined) {
|
||||
return this.#getAllByIndex(index);
|
||||
}
|
||||
if (range !== undefined) {
|
||||
return this.db.getAll(this.name, IDBKeyRange.bound(range.from, range.to));
|
||||
}
|
||||
if (offset !== undefined) {
|
||||
return this.#getAllByOffset(offset.value, offset.direction, limit);
|
||||
}
|
||||
return this.db.getAll(this.name, undefined, limit);
|
||||
}
|
||||
|
||||
async #getAllByIndex(index: Index) {
|
||||
let result = new Set();
|
||||
for (const key in index) {
|
||||
const value = index[key];
|
||||
if (Array.isArray(value)) {
|
||||
for (const idx of value) {
|
||||
const values = await this.db.getAllFromIndex(this.name, key, idx);
|
||||
result = new Set([...result, ...values]);
|
||||
}
|
||||
} else {
|
||||
const values = await this.db.getAllFromIndex(this.name, key, value);
|
||||
result = new Set([...result, ...values]);
|
||||
}
|
||||
}
|
||||
return Array.from(result);
|
||||
}
|
||||
|
||||
async #getAllByOffset(value: string, direction: 1 | -1, limit?: number) {
|
||||
if (direction === 1) {
|
||||
return this.db.getAll(this.name, IDBKeyRange.lowerBound(value), limit);
|
||||
}
|
||||
return this.#getAllByDescOffset(value, limit);
|
||||
}
|
||||
|
||||
async #getAllByDescOffset(value: string, limit?: number) {
|
||||
if (limit === undefined) {
|
||||
return this.db.getAll(this.name, IDBKeyRange.upperBound(value));
|
||||
}
|
||||
const result = [];
|
||||
let cursor = await this.db
|
||||
.transaction(this.name, "readonly")
|
||||
.store.openCursor(IDBKeyRange.upperBound(value), "prev");
|
||||
for (let i = 0; i < limit; i++) {
|
||||
if (cursor === null) {
|
||||
break;
|
||||
}
|
||||
result.push(cursor.value);
|
||||
cursor = await cursor.continue();
|
||||
}
|
||||
return result.reverse();
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Update
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async updateOne(filter: Filter<WithId<TSchema>>, operators: UpdateFilter<TSchema>): Promise<UpdateResult> {
|
||||
if (typeof filter.id === "string") {
|
||||
return this.#update(filter.id, filter, operators);
|
||||
}
|
||||
const documents = await this.find(filter);
|
||||
if (documents.length > 0) {
|
||||
return this.#update(documents[0].id, filter, operators);
|
||||
}
|
||||
return new UpdateResult(0, 0);
|
||||
}
|
||||
|
||||
async updateMany(filter: Filter<WithId<TSchema>>, operators: UpdateFilter<TSchema>): Promise<UpdateResult> {
|
||||
const logger = new UpdateLog(this.name, { filter, operators });
|
||||
|
||||
const ids = await this.find(filter).then((data) => data.map((d) => d.id));
|
||||
|
||||
const documents: WithId<TSchema>[] = [];
|
||||
let modifiedCount = 0;
|
||||
|
||||
const tx = this.db.transaction(this.name, "readwrite", { durability: "relaxed" });
|
||||
await Promise.all(
|
||||
ids.map((id) =>
|
||||
tx.store.get(id).then((current) => {
|
||||
if (current === undefined) {
|
||||
return;
|
||||
}
|
||||
const { modified, document } = update<TSchema>(filter, operators, current);
|
||||
if (modified) {
|
||||
modifiedCount += 1;
|
||||
documents.push(document);
|
||||
return tx.store.put(document);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await tx.done;
|
||||
|
||||
this.broadcast("updateMany", documents);
|
||||
this.#cache.flush();
|
||||
|
||||
this.log(logger.result());
|
||||
|
||||
return new UpdateResult(ids.length, modifiedCount);
|
||||
}
|
||||
|
||||
async replace(filter: Filter<WithId<TSchema>>, document: TSchema): Promise<UpdateResult> {
|
||||
const logger = new ReplaceLog(this.name, document);
|
||||
|
||||
const ids = await this.find(filter).then((data) => data.map((d) => d.id));
|
||||
|
||||
const documents: WithId<TSchema>[] = [];
|
||||
const count = ids.length;
|
||||
|
||||
const tx = this.db.transaction(this.name, "readwrite", { durability: "relaxed" });
|
||||
await Promise.all(
|
||||
ids.map((id) => {
|
||||
const next = { ...document, id };
|
||||
documents.push(next);
|
||||
return tx.store.put(next);
|
||||
})
|
||||
);
|
||||
await tx.done;
|
||||
|
||||
this.broadcast("updateMany", documents);
|
||||
this.#cache.flush();
|
||||
|
||||
this.log(logger.result({ count }));
|
||||
|
||||
return new UpdateResult(count, count);
|
||||
}
|
||||
|
||||
async #update(
|
||||
id: string | number,
|
||||
filter: Filter<WithId<TSchema>>,
|
||||
operators: UpdateFilter<TSchema>
|
||||
): Promise<UpdateResult> {
|
||||
const logger = new UpdateLog(this.name, { filter, operators });
|
||||
|
||||
const tx = this.db.transaction(this.name, "readwrite", { durability: "relaxed" });
|
||||
|
||||
const current = await tx.store.get(id);
|
||||
if (current === undefined) {
|
||||
await tx.done;
|
||||
return new UpdateResult(0, 0);
|
||||
}
|
||||
|
||||
const { modified, document } = await update<TSchema>(filter, operators, current);
|
||||
if (modified === true) {
|
||||
await tx.store.put(document);
|
||||
}
|
||||
await tx.done;
|
||||
|
||||
if (modified === true) {
|
||||
this.broadcast("updateOne", document);
|
||||
this.log(logger.result());
|
||||
this.#cache.flush();
|
||||
return new UpdateResult(1, 1);
|
||||
}
|
||||
|
||||
return new UpdateResult(1);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Remove
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async remove(filter: Filter<WithId<TSchema>>): Promise<RemoveResult> {
|
||||
const logger = new RemoveLog(this.name, { filter });
|
||||
|
||||
const documents = await this.find(filter);
|
||||
const tx = this.db.transaction(this.name, "readwrite");
|
||||
|
||||
await Promise.all(documents.map((data) => tx.store.delete(data.id)));
|
||||
await tx.done;
|
||||
|
||||
this.broadcast("remove", documents);
|
||||
this.#cache.flush();
|
||||
|
||||
this.log(logger.result({ count: documents.length }));
|
||||
|
||||
return new RemoveResult(documents.length);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Count
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async count(filter?: Filter<WithId<TSchema>>): Promise<number> {
|
||||
if (filter !== undefined) {
|
||||
return (await this.find(filter)).length;
|
||||
}
|
||||
return this.db.count(this.name);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Flush
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.db.clear(this.name);
|
||||
this.#cache.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utils
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export function isObject(v: AnyVal): v is object {
|
||||
if (!v) {
|
||||
return false;
|
||||
}
|
||||
const proto = Object.getPrototypeOf(v) as AnyVal;
|
||||
return (proto === OBJECT_PROTOTYPE || proto === null) && OBJECT_TAG === Object.prototype.toString.call(v);
|
||||
}
|
||||
74
src/Databases/IndexedDb.ts
Normal file
74
src/Databases/IndexedDb.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { IDBPDatabase, openDB } from "idb/with-async-ittr";
|
||||
|
||||
import { Collection } from "../Collection.js";
|
||||
import { DBLogger } from "../Logger.js";
|
||||
import { Document } from "../Types.js";
|
||||
import { IndexedDbStorage } from "./IndexedDb.Storage.js";
|
||||
import { Registrars } from "./Registrars.js";
|
||||
|
||||
function log() {}
|
||||
|
||||
type StringRecord<T> = { [x: string]: T };
|
||||
|
||||
type Options = {
|
||||
name: string;
|
||||
version?: number;
|
||||
registrars: Registrars[];
|
||||
log?: DBLogger;
|
||||
};
|
||||
|
||||
export class IndexedDatabase<T extends StringRecord<Document>> {
|
||||
readonly #collections = new Map<keyof T, Collection<T[keyof T]>>();
|
||||
readonly #db: Promise<IDBPDatabase<unknown>>;
|
||||
|
||||
constructor(readonly options: Options) {
|
||||
this.#db = openDB(options.name, options.version ?? 1, {
|
||||
upgrade: (db: IDBPDatabase) => {
|
||||
for (const { name, indexes = [] } of options.registrars) {
|
||||
const store = db.createObjectStore(name as string, { keyPath: "id" });
|
||||
store.createIndex("id", "id", { unique: true });
|
||||
for (const [keyPath, options] of indexes) {
|
||||
store.createIndex(keyPath, keyPath, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
for (const { name } of options.registrars) {
|
||||
this.#collections.set(name, new Collection(name, new IndexedDbStorage(name, this.#db, options.log ?? log)));
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Fetchers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
collection<TSchema extends T[Name], Name extends keyof T = keyof T>(name: Name): Collection<TSchema> {
|
||||
const collection = this.#collections.get(name);
|
||||
if (collection === undefined) {
|
||||
throw new Error(`Collection '${name as string}' not found`);
|
||||
}
|
||||
return collection as any;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utilities
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async export(name: string, options?: { offset?: string; limit?: number }): Promise<any[]> {
|
||||
return (await this.#db).getAll(name, options?.offset, options?.limit) ?? [];
|
||||
}
|
||||
|
||||
async flush() {
|
||||
for (const collection of this.#collections.values()) {
|
||||
collection.flush();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.#db.then((db) => db.close());
|
||||
}
|
||||
}
|
||||
149
src/Databases/MemoryDb.Storage.ts
Normal file
149
src/Databases/MemoryDb.Storage.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Query } from "mingo";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import {
|
||||
addOptions,
|
||||
DuplicateDocumentError,
|
||||
getInsertManyResult,
|
||||
getInsertOneResult,
|
||||
InsertManyResult,
|
||||
InsertOneResult,
|
||||
Options,
|
||||
RemoveResult,
|
||||
Storage,
|
||||
update,
|
||||
UpdateResult
|
||||
} from "../Storage/mod.js";
|
||||
import { Document, Filter, UpdateFilter, WithId } from "../Types.js";
|
||||
|
||||
export class MemoryStorage<TSchema extends Document = Document> extends Storage<TSchema> {
|
||||
readonly #documents = new Map<string, WithId<TSchema>>();
|
||||
|
||||
async resolve() {
|
||||
return this;
|
||||
}
|
||||
|
||||
async has(id: string): Promise<boolean> {
|
||||
return this.#documents.has(id);
|
||||
}
|
||||
|
||||
async insertOne(data: Partial<TSchema>): Promise<InsertOneResult> {
|
||||
const document = { ...data, id: data.id ?? nanoid() } as WithId<TSchema>;
|
||||
if (await this.has(document.id)) {
|
||||
throw new DuplicateDocumentError(document, this as any);
|
||||
}
|
||||
this.#documents.set(document.id, document);
|
||||
this.broadcast("insertOne", document);
|
||||
return getInsertOneResult(document);
|
||||
}
|
||||
|
||||
async insertMany(documents: Partial<TSchema>[]): Promise<InsertManyResult> {
|
||||
const result: TSchema[] = [];
|
||||
for (const data of documents) {
|
||||
const document = { ...data, id: data.id ?? nanoid() } as WithId<TSchema>;
|
||||
result.push(document);
|
||||
this.#documents.set(document.id, document);
|
||||
}
|
||||
|
||||
this.broadcast("insertMany", result);
|
||||
|
||||
return getInsertManyResult(result);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WithId<TSchema> | undefined> {
|
||||
return this.#documents.get(id);
|
||||
}
|
||||
|
||||
async find(filter?: Filter<WithId<TSchema>>, options?: Options): Promise<WithId<TSchema>[]> {
|
||||
let cursor = new Query(filter ?? {}).find(Array.from(this.#documents.values()));
|
||||
if (options !== undefined) {
|
||||
cursor = addOptions(cursor, options);
|
||||
}
|
||||
return cursor.all() as WithId<TSchema>[];
|
||||
}
|
||||
|
||||
async updateOne(filter: Filter<WithId<TSchema>>, operators: UpdateFilter<TSchema>): Promise<UpdateResult> {
|
||||
const query = new Query(filter);
|
||||
for (const current of Array.from(this.#documents.values())) {
|
||||
if (query.test(current) === true) {
|
||||
const { modified, document } = update<TSchema>(filter, operators, current);
|
||||
if (modified === true) {
|
||||
this.#documents.set(document.id, document);
|
||||
this.broadcast("updateOne", document);
|
||||
return new UpdateResult(1, 1);
|
||||
}
|
||||
return new UpdateResult(1, 0);
|
||||
}
|
||||
}
|
||||
return new UpdateResult(0, 0);
|
||||
}
|
||||
|
||||
async updateMany(filter: Filter<WithId<TSchema>>, operators: UpdateFilter<TSchema>): Promise<UpdateResult> {
|
||||
const query = new Query(filter);
|
||||
|
||||
const documents: WithId<TSchema>[] = [];
|
||||
|
||||
let matchedCount = 0;
|
||||
let modifiedCount = 0;
|
||||
|
||||
for (const current of Array.from(this.#documents.values())) {
|
||||
if (query.test(current) === true) {
|
||||
matchedCount += 1;
|
||||
const { modified, document } = update<TSchema>(filter, operators, current);
|
||||
if (modified === true) {
|
||||
modifiedCount += 1;
|
||||
documents.push(document);
|
||||
this.#documents.set(document.id, document);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.broadcast("updateMany", documents);
|
||||
|
||||
return new UpdateResult(matchedCount, modifiedCount);
|
||||
}
|
||||
|
||||
async replace(filter: Filter<WithId<TSchema>>, document: WithId<TSchema>): Promise<UpdateResult> {
|
||||
const query = new Query(filter);
|
||||
|
||||
const documents: WithId<TSchema>[] = [];
|
||||
|
||||
let matchedCount = 0;
|
||||
let modifiedCount = 0;
|
||||
|
||||
for (const current of Array.from(this.#documents.values())) {
|
||||
if (query.test(current) === true) {
|
||||
matchedCount += 1;
|
||||
modifiedCount += 1;
|
||||
documents.push(document);
|
||||
this.#documents.set(document.id, document);
|
||||
}
|
||||
}
|
||||
|
||||
this.broadcast("updateMany", documents);
|
||||
|
||||
return new UpdateResult(matchedCount, modifiedCount);
|
||||
}
|
||||
|
||||
async remove(filter: Filter<WithId<TSchema>>): Promise<RemoveResult> {
|
||||
const documents = Array.from(this.#documents.values());
|
||||
const query = new Query(filter);
|
||||
let count = 0;
|
||||
for (const document of documents) {
|
||||
if (query.test(document) === true) {
|
||||
this.#documents.delete(document.id);
|
||||
this.broadcast("remove", document);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return new RemoveResult(count);
|
||||
}
|
||||
|
||||
async count(filter?: Filter<WithId<TSchema>>): Promise<number> {
|
||||
return new Query(filter ?? {}).find(Array.from(this.#documents.values())).count();
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
this.#documents.clear();
|
||||
}
|
||||
}
|
||||
40
src/Databases/MemoryDb.ts
Normal file
40
src/Databases/MemoryDb.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Collection } from "../Collection.js";
|
||||
import { Document } from "../Types.js";
|
||||
import { MemoryStorage } from "./MemoryDb.Storage.js";
|
||||
import { Registrars } from "./Registrars.js";
|
||||
|
||||
export class MemoryDatabase<T extends Record<string, Document>> {
|
||||
readonly #collections = new Map<keyof T, Collection<T[keyof T]>>();
|
||||
|
||||
register(registrars: Registrars[]): void {
|
||||
for (const { name } of registrars) {
|
||||
this.#collections.set(name, new Collection(name, new MemoryStorage(name)));
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Fetchers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
collection<Name extends keyof T>(name: Name): Collection<T[Name]> {
|
||||
const collection = this.#collections.get(name);
|
||||
if (collection === undefined) {
|
||||
throw new Error(`Collection '${name as string}' not found`);
|
||||
}
|
||||
return collection as any;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utilities
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async flush() {
|
||||
for (const collection of this.#collections.values()) {
|
||||
collection.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
139
src/Databases/Observer.Storage.ts
Normal file
139
src/Databases/Observer.Storage.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Query } from "mingo";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import {
|
||||
addOptions,
|
||||
DuplicateDocumentError,
|
||||
getInsertManyResult,
|
||||
getInsertOneResult,
|
||||
InsertManyResult,
|
||||
InsertOneResult,
|
||||
Options,
|
||||
RemoveResult,
|
||||
Storage,
|
||||
update,
|
||||
UpdateResult
|
||||
} from "../Storage/mod.js";
|
||||
import { Document, Filter, UpdateFilter, WithId } from "../Types.js";
|
||||
|
||||
export class ObserverStorage<TSchema extends Document = Document> extends Storage<TSchema> {
|
||||
readonly #documents = new Map<string, WithId<TSchema>>();
|
||||
|
||||
async resolve() {
|
||||
return this;
|
||||
}
|
||||
|
||||
async has(id: string): Promise<boolean> {
|
||||
return this.#documents.has(id);
|
||||
}
|
||||
|
||||
async insertOne(data: Partial<TSchema>): Promise<InsertOneResult> {
|
||||
const document = { ...data, id: data.id ?? nanoid() } as WithId<TSchema>;
|
||||
if (await this.has(document.id)) {
|
||||
throw new DuplicateDocumentError(document, this as any);
|
||||
}
|
||||
this.#documents.set(document.id, document);
|
||||
return getInsertOneResult(document);
|
||||
}
|
||||
|
||||
async insertMany(documents: Partial<TSchema>[]): Promise<InsertManyResult> {
|
||||
const result: TSchema[] = [];
|
||||
for (const data of documents) {
|
||||
const document = { ...data, id: data.id ?? nanoid() } as WithId<TSchema>;
|
||||
result.push(document);
|
||||
this.#documents.set(document.id, document);
|
||||
}
|
||||
return getInsertManyResult(result);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WithId<TSchema> | undefined> {
|
||||
return this.#documents.get(id);
|
||||
}
|
||||
|
||||
async find(filter?: Filter<WithId<TSchema>>, options?: Options): Promise<WithId<TSchema>[]> {
|
||||
let cursor = new Query(filter ?? {}).find(Array.from(this.#documents.values()));
|
||||
if (options !== undefined) {
|
||||
cursor = addOptions(cursor, options);
|
||||
}
|
||||
return cursor.all() as WithId<TSchema>[];
|
||||
}
|
||||
|
||||
async updateOne(filter: Filter<WithId<TSchema>>, operators: UpdateFilter<TSchema>): Promise<UpdateResult> {
|
||||
const query = new Query(filter);
|
||||
for (const current of Array.from(this.#documents.values())) {
|
||||
if (query.test(current) === true) {
|
||||
const { modified, document } = update<TSchema>(filter, operators, current);
|
||||
if (modified === true) {
|
||||
this.#documents.set(document.id, document);
|
||||
return new UpdateResult(1, 1);
|
||||
}
|
||||
return new UpdateResult(1, 0);
|
||||
}
|
||||
}
|
||||
return new UpdateResult(0, 0);
|
||||
}
|
||||
|
||||
async updateMany(filter: Filter<WithId<TSchema>>, operators: UpdateFilter<TSchema>): Promise<UpdateResult> {
|
||||
const query = new Query(filter);
|
||||
|
||||
const documents: WithId<TSchema>[] = [];
|
||||
|
||||
let matchedCount = 0;
|
||||
let modifiedCount = 0;
|
||||
|
||||
for (const current of Array.from(this.#documents.values())) {
|
||||
if (query.test(current) === true) {
|
||||
matchedCount += 1;
|
||||
const { modified, document } = update<TSchema>(filter, operators, current);
|
||||
if (modified === true) {
|
||||
modifiedCount += 1;
|
||||
documents.push(document);
|
||||
this.#documents.set(document.id, document);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new UpdateResult(matchedCount, modifiedCount);
|
||||
}
|
||||
|
||||
async replace(filter: Filter<WithId<TSchema>>, document: WithId<TSchema>): Promise<UpdateResult> {
|
||||
const query = new Query(filter);
|
||||
|
||||
const documents: WithId<TSchema>[] = [];
|
||||
|
||||
let matchedCount = 0;
|
||||
let modifiedCount = 0;
|
||||
|
||||
for (const current of Array.from(this.#documents.values())) {
|
||||
if (query.test(current) === true) {
|
||||
matchedCount += 1;
|
||||
modifiedCount += 1;
|
||||
documents.push(document);
|
||||
this.#documents.set(document.id, document);
|
||||
}
|
||||
}
|
||||
|
||||
return new UpdateResult(matchedCount, modifiedCount);
|
||||
}
|
||||
|
||||
async remove(filter: Filter<WithId<TSchema>>): Promise<RemoveResult> {
|
||||
const documents = Array.from(this.#documents.values());
|
||||
const query = new Query(filter);
|
||||
let count = 0;
|
||||
for (const document of documents) {
|
||||
if (query.test(document) === true) {
|
||||
this.#documents.delete(document.id);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return new RemoveResult(count);
|
||||
}
|
||||
|
||||
async count(filter?: Filter<WithId<TSchema>>): Promise<number> {
|
||||
return new Query(filter ?? {}).find(Array.from(this.#documents.values())).count();
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
this.#documents.clear();
|
||||
}
|
||||
}
|
||||
6
src/Databases/Registrars.ts
Normal file
6
src/Databases/Registrars.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type Registrars = {
|
||||
name: string;
|
||||
indexes?: Index[];
|
||||
};
|
||||
|
||||
type Index = [string, IDBIndexParameters?];
|
||||
2
src/Databases/mod.ts
Normal file
2
src/Databases/mod.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./IndexedDb.js";
|
||||
export * from "./MemoryDb.js";
|
||||
12
src/Hash.ts
Normal file
12
src/Hash.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function hashCodeQuery(filter: unknown, options: unknown): number {
|
||||
const value = JSON.stringify({ filter, options });
|
||||
let hash = 0;
|
||||
if (value.length === 0) {
|
||||
return hash;
|
||||
}
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash = (hash << 5) - hash + value.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
67
src/Logger.ts
Normal file
67
src/Logger.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
class Performance {
|
||||
startedAt = performance.now();
|
||||
endedAt?: number;
|
||||
duration?: number;
|
||||
|
||||
result() {
|
||||
this.endedAt = performance.now();
|
||||
this.duration = Number((this.endedAt - this.startedAt).toFixed(2));
|
||||
}
|
||||
}
|
||||
|
||||
abstract class LogEvent {
|
||||
readonly performance = new Performance();
|
||||
|
||||
data?: Record<string, any>;
|
||||
|
||||
constructor(readonly collection: string, readonly query?: Record<string, any>) {}
|
||||
|
||||
result(data?: Record<string, any>): this {
|
||||
this.performance.result();
|
||||
this.data = data;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Loggers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export class InsertLog extends LogEvent implements DBLogEvent {
|
||||
readonly type = "insert" as const;
|
||||
}
|
||||
|
||||
export class UpdateLog extends LogEvent implements DBLogEvent {
|
||||
readonly type = "update" as const;
|
||||
}
|
||||
|
||||
export class ReplaceLog extends LogEvent implements DBLogEvent {
|
||||
readonly type = "replace" as const;
|
||||
}
|
||||
|
||||
export class RemoveLog extends LogEvent implements DBLogEvent {
|
||||
readonly type = "remove" as const;
|
||||
}
|
||||
|
||||
export class QueryLog extends LogEvent implements DBLogEvent {
|
||||
readonly type = "query" as const;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type DBLogger = (event: DBLogEvent) => void;
|
||||
|
||||
export type DBLogEvent = {
|
||||
type: DBLogEventType;
|
||||
collection: string;
|
||||
performance: Performance;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type DBLogEventType = InsertLog["type"] | UpdateLog["type"] | ReplaceLog["type"] | RemoveLog["type"] | QueryLog["type"];
|
||||
13
src/Observe/Action.ts
Normal file
13
src/Observe/Action.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type Action<T = unknown> =
|
||||
| {
|
||||
type: "insert";
|
||||
instance: T;
|
||||
}
|
||||
| {
|
||||
type: "update";
|
||||
instance: T;
|
||||
}
|
||||
| {
|
||||
type: "remove";
|
||||
instance: T;
|
||||
};
|
||||
10
src/Observe/IsMatch.ts
Normal file
10
src/Observe/IsMatch.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Query } from "mingo";
|
||||
|
||||
import { Document, Filter, WithId } from "../Types.js";
|
||||
|
||||
export function isMatch<TSchema extends Document = Document>(
|
||||
document: WithId<TSchema>,
|
||||
filter?: Filter<WithId<TSchema>>
|
||||
): boolean {
|
||||
return !filter || new Query(filter).test(document);
|
||||
}
|
||||
75
src/Observe/Observe.ts
Normal file
75
src/Observe/Observe.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Query } from "mingo";
|
||||
|
||||
import { Collection } from "../Collection.js";
|
||||
import { addOptions, ChangeEvent, Options } from "../Storage/mod.js";
|
||||
import { Document, Filter, WithId } from "../Types.js";
|
||||
import { Store } from "./Store.js";
|
||||
|
||||
export function observe<TSchema extends Document = Document>(
|
||||
collection: Collection<TSchema>,
|
||||
filter: Filter<WithId<TSchema>>,
|
||||
options: Options | undefined,
|
||||
onChange: (documents: WithId<TSchema>[], changed: WithId<TSchema>[], type: ChangeEvent<TSchema>["type"]) => void
|
||||
): {
|
||||
unsubscribe: () => void;
|
||||
} {
|
||||
const store = Store.create<TSchema>();
|
||||
|
||||
let debounce: NodeJS.Timeout;
|
||||
|
||||
collection.find(filter, options).then(async (documents) => {
|
||||
const resolved = await store.resolve(documents);
|
||||
onChange(resolved, resolved, "insertMany");
|
||||
});
|
||||
|
||||
const subscriptions = [
|
||||
collection.observable.flush.subscribe(() => {
|
||||
clearTimeout(debounce);
|
||||
store.flush();
|
||||
onChange([], [], "remove");
|
||||
}),
|
||||
collection.observable.change.subscribe(async ({ type, data }) => {
|
||||
let changed: WithId<TSchema>[] = [];
|
||||
switch (type) {
|
||||
case "insertOne":
|
||||
case "updateOne": {
|
||||
changed = await store[type](data, filter);
|
||||
break;
|
||||
}
|
||||
case "insertMany":
|
||||
case "updateMany":
|
||||
case "remove": {
|
||||
changed = await store[type](data, filter);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (changed.length > 0) {
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(() => {
|
||||
store.getDocuments().then((documents) => {
|
||||
onChange(applyQueryOptions(documents, options), changed, type);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
})
|
||||
];
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
store.destroy();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function applyQueryOptions<TSchema extends Document = Document>(
|
||||
documents: WithId<TSchema>[],
|
||||
options?: Options
|
||||
): WithId<TSchema>[] {
|
||||
if (options !== undefined) {
|
||||
return addOptions(new Query({}).find(documents), options).all() as WithId<TSchema>[];
|
||||
}
|
||||
return documents;
|
||||
}
|
||||
40
src/Observe/ObserveOne.ts
Normal file
40
src/Observe/ObserveOne.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Collection } from "../Collection.js";
|
||||
import { Document, Filter, WithId } from "../Types.js";
|
||||
import { isMatch } from "./IsMatch.js";
|
||||
|
||||
export function observeOne<TSchema extends Document = Document>(
|
||||
collection: Collection<TSchema>,
|
||||
filter: Filter<WithId<TSchema>>,
|
||||
onChange: (document: Document | undefined) => void
|
||||
): {
|
||||
unsubscribe: () => void;
|
||||
} {
|
||||
collection.findOne(filter).then(onChange);
|
||||
|
||||
const subscription = collection.observable.change.subscribe(({ type, data }) => {
|
||||
switch (type) {
|
||||
case "insertOne":
|
||||
case "updateOne": {
|
||||
if (isMatch<TSchema>(data, filter) === true) {
|
||||
onChange(data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "remove": {
|
||||
for (const document of data) {
|
||||
if (isMatch<TSchema>(document, filter) === true) {
|
||||
onChange(undefined);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
};
|
||||
}
|
||||
85
src/Observe/Store.ts
Normal file
85
src/Observe/Store.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { ObserverStorage } from "../Databases/Observer.Storage.js";
|
||||
import { Storage } from "../Storage/mod.js";
|
||||
import { Document, Filter, WithId } from "../Types.js";
|
||||
import { isMatch } from "./IsMatch.js";
|
||||
|
||||
export class Store<TSchema extends Document = Document> {
|
||||
private constructor(private storage: Storage<TSchema>) {}
|
||||
|
||||
static create<TSchema extends Document = Document>() {
|
||||
return new Store<TSchema>(new ObserverStorage<TSchema>(`observer[${nanoid()}]`));
|
||||
}
|
||||
|
||||
get destroy() {
|
||||
return this.storage.destroy.bind(this.storage);
|
||||
}
|
||||
|
||||
async resolve(documents: WithId<TSchema>[]): Promise<WithId<TSchema>[]> {
|
||||
await this.storage.insertMany(documents);
|
||||
return this.getDocuments();
|
||||
}
|
||||
|
||||
async getDocuments(): Promise<WithId<TSchema>[]> {
|
||||
return this.storage.find();
|
||||
}
|
||||
|
||||
async insertMany(documents: WithId<TSchema>[], filter: Filter<WithId<TSchema>>): Promise<WithId<TSchema>[]> {
|
||||
const matched = [];
|
||||
for (const document of documents) {
|
||||
matched.push(...(await this.insertOne(document, filter)));
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
async insertOne(document: WithId<TSchema>, filter: Filter<WithId<TSchema>>): Promise<WithId<TSchema>[]> {
|
||||
if (isMatch<TSchema>(document, filter)) {
|
||||
await this.storage.insertOne(document);
|
||||
return [document];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async updateMany(documents: WithId<TSchema>[], filter: Filter<WithId<TSchema>>): Promise<WithId<TSchema>[]> {
|
||||
const matched = [];
|
||||
for (const document of documents) {
|
||||
matched.push(...(await this.updateOne(document, filter)));
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
async updateOne(document: WithId<TSchema>, filter: Filter<WithId<TSchema>>): Promise<WithId<TSchema>[]> {
|
||||
if (await this.storage.has(document.id)) {
|
||||
await this.#updateOrRemove(document, filter);
|
||||
return [document];
|
||||
} else if (isMatch<TSchema>(document, filter)) {
|
||||
await this.storage.insertOne(document);
|
||||
return [document];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async remove(documents: WithId<TSchema>[]): Promise<WithId<TSchema>[]> {
|
||||
const matched = [];
|
||||
for (const document of documents) {
|
||||
if (isMatch<TSchema>(document, { id: document.id } as WithId<TSchema>)) {
|
||||
await this.storage.remove({ id: document.id } as WithId<TSchema>);
|
||||
matched.push(document);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
async #updateOrRemove(document: WithId<TSchema>, filter: Filter<WithId<TSchema>>): Promise<void> {
|
||||
if (isMatch<TSchema>(document, filter)) {
|
||||
await this.storage.replace({ id: document.id } as WithId<TSchema>, document);
|
||||
} else {
|
||||
await this.storage.remove({ id: document.id } as WithId<TSchema>);
|
||||
}
|
||||
}
|
||||
|
||||
flush() {
|
||||
this.storage.flush();
|
||||
}
|
||||
}
|
||||
3
src/Observe/mod.ts
Normal file
3
src/Observe/mod.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type { Action } from "./Action.js";
|
||||
export * from "./Observe.js";
|
||||
export * from "./ObserveOne.js";
|
||||
30
src/Storage/Errors.ts
Normal file
30
src/Storage/Errors.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { RawObject } from "mingo/types";
|
||||
|
||||
import { Document } from "../Types.js";
|
||||
import type { Storage } from "./Storage.js";
|
||||
|
||||
export class DuplicateDocumentError extends Error {
|
||||
readonly type = "DuplicateDocumentError";
|
||||
|
||||
constructor(readonly document: Document, storage: Storage) {
|
||||
super(
|
||||
`Collection Insert Violation: Document '${document.id}' already exists in ${storage.name} collection ${storage.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class DocumentNotFoundError extends Error {
|
||||
readonly type = "DocumentNotFoundError";
|
||||
|
||||
constructor(readonly criteria: RawObject) {
|
||||
super(`Collection Update Violation: Document matching criteria does not exists`);
|
||||
}
|
||||
}
|
||||
|
||||
export class PullUpdateArrayError extends Error {
|
||||
readonly type = "PullUpdateArrayError";
|
||||
|
||||
constructor(document: string, key: string) {
|
||||
super(`Collection Update Violation: Document '${document}' $pull operation failed, '${key}' is not an array`);
|
||||
}
|
||||
}
|
||||
40
src/Storage/Operators/Insert/Result.ts
Normal file
40
src/Storage/Operators/Insert/Result.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Document } from "../../../Types.js";
|
||||
|
||||
export function getInsertManyResult(documents: Document[]): InsertManyResult {
|
||||
return {
|
||||
acknowledged: true,
|
||||
insertedCount: documents.length,
|
||||
insertedIds: documents.reduce<{ [key: number]: string | number }>((map, document, index) => {
|
||||
map[index] = document.id;
|
||||
return map;
|
||||
}, {})
|
||||
};
|
||||
}
|
||||
|
||||
export function getInsertOneResult(document: Document): InsertOneResult {
|
||||
return {
|
||||
acknowledged: true,
|
||||
insertedId: document.id
|
||||
};
|
||||
}
|
||||
|
||||
export type InsertManyResult =
|
||||
| {
|
||||
acknowledged: false;
|
||||
}
|
||||
| {
|
||||
acknowledged: true;
|
||||
insertedCount: number;
|
||||
insertedIds: {
|
||||
[key: number]: string | number;
|
||||
};
|
||||
};
|
||||
|
||||
export type InsertOneResult =
|
||||
| {
|
||||
acknowledged: false;
|
||||
}
|
||||
| {
|
||||
acknowledged: true;
|
||||
insertedId: string | number;
|
||||
};
|
||||
1
src/Storage/Operators/Insert/mod.ts
Normal file
1
src/Storage/Operators/Insert/mod.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./Result.js";
|
||||
3
src/Storage/Operators/Remove/Result.ts
Normal file
3
src/Storage/Operators/Remove/Result.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export class RemoveResult {
|
||||
constructor(readonly matched = 0) {}
|
||||
}
|
||||
1
src/Storage/Operators/Remove/mod.ts
Normal file
1
src/Storage/Operators/Remove/mod.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./Result.js";
|
||||
56
src/Storage/Operators/Update/Inc.ts
Normal file
56
src/Storage/Operators/Update/Inc.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as dot from "dot-prop";
|
||||
|
||||
import { Document, Filter, UpdateFilter, WithId } from "../../../Types.js";
|
||||
import { setPositionalData } from "./Utils.js";
|
||||
|
||||
/**
|
||||
* Execute a $inc based operators.
|
||||
*
|
||||
* Supports positional array operator $(update)
|
||||
*
|
||||
* @see https://www.mongodb.com/docs/manual/reference/operator/update/positional
|
||||
*
|
||||
* @param document - Document being updated.
|
||||
* @param filter - Search filter provided with the operation. Eg. updateOne({ id: "1" })
|
||||
* @param $set - $set action being executed.
|
||||
*/
|
||||
export function $inc<TSchema extends Document = Document>(
|
||||
document: WithId<TSchema>,
|
||||
filter: Filter<WithId<TSchema>>,
|
||||
$inc: UpdateFilter<TSchema>["$inc"] = {}
|
||||
): boolean {
|
||||
let modified = false;
|
||||
for (const key in $inc) {
|
||||
if (key.includes("$")) {
|
||||
if (
|
||||
setPositionalData(document, filter, key, {
|
||||
object: (data, key, target) => {
|
||||
if (typeof data === "number") {
|
||||
return data + ($inc[key] as number);
|
||||
}
|
||||
const value = dot.getProperty(data, target);
|
||||
if (typeof value !== "number") {
|
||||
return 0;
|
||||
}
|
||||
return value + $inc[key];
|
||||
},
|
||||
value: (data, key) => data + $inc[key]
|
||||
})
|
||||
) {
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
document = increment(document, key, $inc[key]);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
}
|
||||
|
||||
function increment<D extends Document>(document: D, key: string, value: number): D {
|
||||
let currentValue = dot.getProperty(document, key) as unknown;
|
||||
if (typeof currentValue !== "number") {
|
||||
currentValue = 0;
|
||||
}
|
||||
return dot.setProperty(document, key, (currentValue as number) + value);
|
||||
}
|
||||
60
src/Storage/Operators/Update/Pull.ts
Normal file
60
src/Storage/Operators/Update/Pull.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as dot from "dot-prop";
|
||||
import { Query } from "mingo";
|
||||
import { RawObject } from "mingo/types";
|
||||
|
||||
import { Document, UpdateFilter, WithId } from "../../../Types.js";
|
||||
import { PullUpdateArrayError } from "../../Errors.js";
|
||||
|
||||
export function $pull<TSchema extends Document>(
|
||||
document: WithId<TSchema>,
|
||||
operator: UpdateFilter<TSchema>["$pull"] = {}
|
||||
): boolean {
|
||||
let modified = false;
|
||||
for (const key in operator) {
|
||||
const values = getPullValues(document, key);
|
||||
const result = getPullResult(operator, key, values);
|
||||
dot.setProperty(document, key, result);
|
||||
if (result.length !== values.length) {
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
}
|
||||
|
||||
function getPullValues(document: Document, key: string): any[] {
|
||||
const values: any[] | undefined = dot.getProperty(document, key);
|
||||
if (values === undefined || Array.isArray(values) === false) {
|
||||
throw new PullUpdateArrayError(document.id, key);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function getPullResult(operator: RawObject, key: string, values: any[]): any[] {
|
||||
if (typeof operator[key] === "object") {
|
||||
return new Query(getPullCriteria(operator, key)).remove(values);
|
||||
}
|
||||
return values.filter((value) => value !== operator[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Criteria used during pull depends on the structure of the query under the pulled
|
||||
* key. If the object has a mongodb filter key with a $ prefix we need to provide
|
||||
* the query with the array key as the query wrapper. If a $ prefix is not present
|
||||
* we want the value under the key being the criteria.
|
||||
*
|
||||
* @param operator - Object under operator action.
|
||||
* @param key - Specific key being resolved to a criteria.
|
||||
*/
|
||||
function getPullCriteria(operator: any, key: string): RawObject {
|
||||
let hasFilters = false;
|
||||
for (const left in operator[key]) {
|
||||
if (left.includes("$")) {
|
||||
hasFilters = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasFilters === true) {
|
||||
return { [key]: dot.getProperty(operator, key) };
|
||||
}
|
||||
return dot.getProperty(operator, key) as RawObject;
|
||||
}
|
||||
72
src/Storage/Operators/Update/Push.ts
Normal file
72
src/Storage/Operators/Update/Push.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as dot from "dot-prop";
|
||||
import { deepEqual } from "fast-equals";
|
||||
import { Query } from "mingo";
|
||||
import type { RawObject } from "mingo/types";
|
||||
|
||||
import { Document, UpdateFilter, WithId } from "../../../Types.js";
|
||||
|
||||
export function $push<TSchema extends Document = Document>(
|
||||
document: WithId<TSchema>,
|
||||
operator: UpdateFilter<TSchema>["$push"] = {}
|
||||
): boolean {
|
||||
let modified = false;
|
||||
for (const key in operator) {
|
||||
const values = getPushValues(document, key);
|
||||
const result = getPushResult(operator, key, values);
|
||||
dot.setProperty(document, key, result);
|
||||
if (deepEqual(values, result) === false) {
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
}
|
||||
|
||||
function getPushValues(document: any, key: string): any[] {
|
||||
const values = dot.getProperty(document, key);
|
||||
if (values === undefined) {
|
||||
return [];
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function getPushResult(operator: RawObject, key: string, values: any[]): any[] {
|
||||
if (typeof operator[key] === "object") {
|
||||
return getPushFromModifiers(operator[key], values);
|
||||
}
|
||||
return [...values, operator[key]];
|
||||
}
|
||||
|
||||
function getPushFromModifiers(obj: any, values: any[]): any[] {
|
||||
if (obj.$each === undefined) {
|
||||
return [...values, obj];
|
||||
}
|
||||
let items: any[];
|
||||
|
||||
if (obj.$position !== undefined) {
|
||||
items = [...values.slice(0, obj.$position), ...obj.$each, ...values.slice(obj.$position)];
|
||||
} else {
|
||||
items = [...values, ...obj.$each];
|
||||
}
|
||||
|
||||
if (obj.$sort !== undefined) {
|
||||
if (typeof obj.$sort === "object") {
|
||||
items = new Query({}).find(items).sort(obj.$sort).all();
|
||||
} else {
|
||||
items = items.sort((a, b) => {
|
||||
if (obj.$sort === 1) {
|
||||
return a < b ? -1 : 1;
|
||||
}
|
||||
return a < b ? 1 : -1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.$slice !== undefined) {
|
||||
if (obj.$slice < 0) {
|
||||
return items.slice(obj.$slice);
|
||||
}
|
||||
return items.slice(0, obj.$slice);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
3
src/Storage/Operators/Update/Result.ts
Normal file
3
src/Storage/Operators/Update/Result.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export class UpdateResult {
|
||||
constructor(readonly matched = 0, readonly modified = 0) {}
|
||||
}
|
||||
47
src/Storage/Operators/Update/Set.ts
Normal file
47
src/Storage/Operators/Update/Set.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as dot from "dot-prop";
|
||||
|
||||
import { Document, Filter, UpdateFilter, WithId } from "../../../Types.js";
|
||||
import { setPositionalData } from "./Utils.js";
|
||||
|
||||
/**
|
||||
* Execute a $set based operators.
|
||||
*
|
||||
* Supports positional array operator $(update)
|
||||
*
|
||||
* @see https://www.mongodb.com/docs/manual/reference/operator/update/positional
|
||||
*
|
||||
* @param document - Document being updated.
|
||||
* @param filter - Search filter provided with the operation. Eg. updateOne({ id: "1" })
|
||||
* @param $set - $set action being executed.
|
||||
*/
|
||||
export function $set<TSchema extends Document = Document>(
|
||||
document: WithId<WithId<TSchema>>,
|
||||
filter: Filter<WithId<TSchema>>,
|
||||
$set: UpdateFilter<TSchema>["$set"] = {} as any
|
||||
): boolean {
|
||||
let modified = false;
|
||||
for (const key in $set) {
|
||||
if (key.includes("$")) {
|
||||
if (
|
||||
setPositionalData(document, filter, key, {
|
||||
object: (data, key) => getSetValue(data, key, $set),
|
||||
value: (_, key) => $set[key]
|
||||
})
|
||||
) {
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
document = dot.setProperty(document, key, getSetValue(document, key, $set));
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
}
|
||||
|
||||
function getSetValue(data: any, key: string, $set: UpdateFilter<Document>["$set"] = {}) {
|
||||
const value = $set[key];
|
||||
if (typeof value === "function") {
|
||||
return value(dot.getProperty(data, key), data);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
16
src/Storage/Operators/Update/Unset.ts
Normal file
16
src/Storage/Operators/Update/Unset.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as dot from "dot-prop";
|
||||
|
||||
import { Document, UpdateFilter, WithId } from "../../../Types.js";
|
||||
|
||||
export function $unset<TSchema extends Document = Document>(
|
||||
document: WithId<TSchema>,
|
||||
$unset: UpdateFilter<TSchema>["$unset"] = {}
|
||||
): boolean {
|
||||
let modified = false;
|
||||
for (const key in $unset) {
|
||||
if (dot.deleteProperty(document, key)) {
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
}
|
||||
26
src/Storage/Operators/Update/Update.ts
Normal file
26
src/Storage/Operators/Update/Update.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { clone } from "../../../Clone.js";
|
||||
import { Document, Filter, UpdateFilter, WithId } from "../../../Types.js";
|
||||
import { $inc } from "./Inc.js";
|
||||
import { $pull } from "./Pull.js";
|
||||
import { $push } from "./Push.js";
|
||||
import { $set } from "./Set.js";
|
||||
import { $unset } from "./Unset.js";
|
||||
|
||||
export function update<TSchema extends Document>(
|
||||
filter: Filter<WithId<TSchema>>,
|
||||
operators: UpdateFilter<TSchema>,
|
||||
document: WithId<TSchema>
|
||||
) {
|
||||
const updatedDocument = clone(document);
|
||||
|
||||
const setModified = $set<TSchema>(updatedDocument, filter, operators.$set);
|
||||
const runModified = $unset<TSchema>(updatedDocument, operators.$unset);
|
||||
const pushModified = $push<TSchema>(updatedDocument, operators.$push);
|
||||
const pullModified = $pull<TSchema>(updatedDocument, operators.$pull);
|
||||
const incModified = $inc<TSchema>(updatedDocument, filter, operators.$inc);
|
||||
|
||||
return {
|
||||
modified: setModified || runModified || pushModified || pullModified || incModified,
|
||||
document: updatedDocument
|
||||
};
|
||||
}
|
||||
168
src/Storage/Operators/Update/Utils.ts
Normal file
168
src/Storage/Operators/Update/Utils.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import * as dot from "dot-prop";
|
||||
import { deepEqual } from "fast-equals";
|
||||
import { Query } from "mingo";
|
||||
|
||||
import { clone } from "../../../Clone.js";
|
||||
import { Document, Filter, WithId } from "../../../Types.js";
|
||||
|
||||
type UpdateValue = (data: any, key: string, target: string) => any;
|
||||
|
||||
export function setPositionalData<TSchema extends Document = Document>(
|
||||
document: WithId<TSchema>,
|
||||
criteria: Filter<WithId<TSchema>>,
|
||||
key: string,
|
||||
update: {
|
||||
object: UpdateValue;
|
||||
value: UpdateValue;
|
||||
}
|
||||
): boolean {
|
||||
const { filter, path, target } = getPositionalFilter(criteria, key);
|
||||
|
||||
const values = getPropertyValues(document, path);
|
||||
const items =
|
||||
typeof filter === "object"
|
||||
? getPositionalUpdateQuery(clone(values), key, filter, target, update.object)
|
||||
: getPositionalUpdate(clone(values), key, filter, target, update.value);
|
||||
|
||||
dot.setProperty(document, path, items);
|
||||
|
||||
return deepEqual(values, items) === false;
|
||||
}
|
||||
|
||||
function getPropertyValues(document: Document, path: string): string[] {
|
||||
const values = dot.getProperty(document, path);
|
||||
if (values === undefined) {
|
||||
throw new Error("Values is undefined");
|
||||
}
|
||||
if (Array.isArray(values) === false) {
|
||||
throw new Error("Values is not an array");
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function getPositionalUpdate(
|
||||
items: any[],
|
||||
key: string,
|
||||
filter: string,
|
||||
target: string,
|
||||
updateValue: UpdateValue
|
||||
): any[] {
|
||||
let index = 0;
|
||||
for (const item of items) {
|
||||
if (item === filter) {
|
||||
items[index] = updateValue(items[index], key, target);
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function getPositionalUpdateQuery(
|
||||
items: any[],
|
||||
key: string,
|
||||
filter: Filter<any>,
|
||||
target: string,
|
||||
updateValue: UpdateValue
|
||||
): any[] {
|
||||
let index = 0;
|
||||
for (const item of items) {
|
||||
if (new Query(filter).test(item) === true) {
|
||||
if (target === "") {
|
||||
items[index] = updateValue(items[index], key, target);
|
||||
} else {
|
||||
dot.setProperty(item, target, updateValue(items[index], key, target));
|
||||
}
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function getPositionalFilter(criteria: Filter<any>, key: string): PositionalFilter {
|
||||
const [leftPath, rightPath] = key.split("$");
|
||||
|
||||
const lKey = trimSeparators(leftPath);
|
||||
const rKey = trimSeparators(rightPath);
|
||||
|
||||
for (const key in criteria) {
|
||||
const result = getPositionalCriteriaFilter(key, lKey, rKey, criteria);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
filter: criteria[lKey],
|
||||
path: lKey,
|
||||
target: rKey
|
||||
};
|
||||
}
|
||||
|
||||
function getPositionalCriteriaFilter(
|
||||
key: string,
|
||||
lKey: string,
|
||||
rKey: string,
|
||||
criteria: Filter<any>
|
||||
): PositionalFilter | undefined {
|
||||
if (key.includes(lKey) === true) {
|
||||
const isObject = typeof criteria[key] === "object";
|
||||
if (key.includes(".") === true || isObject === true) {
|
||||
return {
|
||||
filter:
|
||||
trimSeparators(key.replace(lKey, "")) === ""
|
||||
? (criteria[key] as any).$elemMatch !== undefined
|
||||
? (criteria[key] as any).$elemMatch
|
||||
: criteria[key]
|
||||
: {
|
||||
[trimSeparators(key.replace(lKey, ""))]: criteria[key]
|
||||
},
|
||||
path: lKey,
|
||||
target: rKey
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function trimSeparators(value: string): string {
|
||||
return value.replace(/^\.+|\.+$/gm, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* A position filter is used to find documents to update in an array of values.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* const document = {
|
||||
* grades: [
|
||||
* { grade: 80, mean: 75, std: 8 },
|
||||
* { grade: 85, mean: 90, std: 5 },
|
||||
* { grade: 85, mean: 85, std: 8 }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* updateOne({ "grades.grade": 85 }, { $set: { "grades.$.std": 6 } } })
|
||||
* ```
|
||||
*
|
||||
* In the above example the filter would be `{ grade: 85 }` which is used to find
|
||||
* objects to update in an array of values.
|
||||
*/
|
||||
type PositionalFilter = {
|
||||
/**
|
||||
* The filter to use to find the values to update in an array.
|
||||
*/
|
||||
filter: any;
|
||||
|
||||
/**
|
||||
* The path to the array of values of the parent document. Eg. `grades`.
|
||||
*/
|
||||
path: string;
|
||||
|
||||
/**
|
||||
* The path to the key to update in the array of values. Eg. `std`.
|
||||
*/
|
||||
target: string;
|
||||
};
|
||||
2
src/Storage/Operators/Update/mod.ts
Normal file
2
src/Storage/Operators/Update/mod.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./Result.js";
|
||||
export * from "./Update.js";
|
||||
177
src/Storage/Storage.ts
Normal file
177
src/Storage/Storage.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Cursor } from "mingo/cursor";
|
||||
import { nanoid } from "nanoid";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import { BroadcastChannel, StorageBroadcast } from "../Broadcast.js";
|
||||
import { Document, Filter, UpdateFilter, WithId } from "../Types.js";
|
||||
import { InsertManyResult, InsertOneResult } from "./Operators/Insert/mod.js";
|
||||
import { RemoveResult } from "./Operators/Remove/mod.js";
|
||||
import { UpdateResult } from "./Operators/Update/mod.js";
|
||||
|
||||
export abstract class Storage<TSchema extends Document = Document> {
|
||||
readonly observable = {
|
||||
change: new Subject<ChangeEvent<TSchema>>(),
|
||||
flush: new Subject<void>()
|
||||
};
|
||||
|
||||
status: Status = "loading";
|
||||
|
||||
readonly #channel: BroadcastChannel;
|
||||
|
||||
constructor(readonly name: string, readonly id = nanoid()) {
|
||||
this.#channel = new BroadcastChannel(`valkyr:db:${name}`);
|
||||
this.#channel.onmessage = ({ data }: MessageEvent<StorageBroadcast<TSchema>>) => {
|
||||
if (data.name !== this.name) {
|
||||
return;
|
||||
}
|
||||
switch (data.type) {
|
||||
case "flush": {
|
||||
this.observable.flush.next();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
this.observable.change.next(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Resolver
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
abstract resolve(): Promise<this>;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Status
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
is(status: Status): boolean {
|
||||
return this.status === status;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Broadcaster
|
||||
|--------------------------------------------------------------------------------
|
||||
|
|
||||
| Broadcast local changes with any change listeners in the current and other
|
||||
| browser tabs and window.
|
||||
|
|
||||
*/
|
||||
|
||||
broadcast(type: StorageBroadcast<TSchema>["type"], data?: TSchema | TSchema[]): void {
|
||||
switch (type) {
|
||||
case "flush": {
|
||||
this.observable.flush.next();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
this.observable.change.next({ type, data: data as any });
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.#channel.postMessage({ name: this.name, type, data });
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Operations
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
abstract has(id: string): Promise<boolean>;
|
||||
|
||||
abstract insertOne(document: Partial<WithId<TSchema>>): Promise<InsertOneResult>;
|
||||
|
||||
abstract insertMany(documents: Partial<WithId<TSchema>>[]): Promise<InsertManyResult>;
|
||||
|
||||
abstract findById(id: string): Promise<WithId<TSchema> | undefined>;
|
||||
|
||||
abstract find(filter?: Filter<WithId<TSchema>>, options?: Options): Promise<WithId<TSchema>[]>;
|
||||
|
||||
abstract updateOne(filter: Filter<WithId<TSchema>>, operators: UpdateFilter<TSchema>): Promise<UpdateResult>;
|
||||
|
||||
abstract updateMany(filter: Filter<WithId<TSchema>>, operators: UpdateFilter<TSchema>): Promise<UpdateResult>;
|
||||
|
||||
abstract replace(filter: Filter<WithId<TSchema>>, document: TSchema): Promise<UpdateResult>;
|
||||
|
||||
abstract remove(filter: Filter<WithId<TSchema>>): Promise<RemoveResult>;
|
||||
|
||||
abstract count(filter?: Filter<WithId<TSchema>>): Promise<number>;
|
||||
|
||||
abstract flush(): Promise<void>;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Destructor
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
destroy() {
|
||||
this.#channel.close();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utilities
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export function addOptions(cursor: Cursor, options: Options): Cursor {
|
||||
if (options.sort) {
|
||||
cursor.sort(options.sort);
|
||||
}
|
||||
if (options.skip !== undefined) {
|
||||
cursor.skip(options.skip);
|
||||
}
|
||||
if (options.limit !== undefined) {
|
||||
cursor.limit(options.limit);
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type Status = "loading" | "ready";
|
||||
|
||||
export type ChangeEvent<TSchema extends Document = Document> =
|
||||
| {
|
||||
type: "insertOne" | "updateOne";
|
||||
data: WithId<TSchema>;
|
||||
}
|
||||
| {
|
||||
type: "insertMany" | "updateMany" | "remove";
|
||||
data: WithId<TSchema>[];
|
||||
};
|
||||
|
||||
export type Options = {
|
||||
sort?: {
|
||||
[key: string]: 1 | -1;
|
||||
};
|
||||
skip?: number;
|
||||
range?: {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
offset?: {
|
||||
value: string;
|
||||
direction: 1 | -1;
|
||||
};
|
||||
limit?: number;
|
||||
index?: Index;
|
||||
};
|
||||
|
||||
export type Index = {
|
||||
[index: string]: any;
|
||||
};
|
||||
5
src/Storage/mod.ts
Normal file
5
src/Storage/mod.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./Errors.js";
|
||||
export * from "./Operators/Insert/mod.js";
|
||||
export * from "./Operators/Remove/mod.js";
|
||||
export * from "./Operators/Update/mod.js";
|
||||
export * from "./Storage.js";
|
||||
174
src/Types.ts
Normal file
174
src/Types.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { BSONRegExp, BSONType } from "bson";
|
||||
|
||||
export type Document = {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type WithId<TSchema> = {
|
||||
id: string;
|
||||
} & TSchema;
|
||||
|
||||
export type Filter<TSchema> = {
|
||||
[P in keyof TSchema]?: Condition<TSchema[P]>;
|
||||
} & RootFilterOperators<TSchema> &
|
||||
Record<string, any>;
|
||||
|
||||
export type UpdateFilter<TSchema> = {
|
||||
$inc?: OnlyFieldsOfType<TSchema, number>;
|
||||
$set?: MatchKeysAndValues<TSchema> | MatchKeysToFunctionValues<TSchema> | Record<string, any>;
|
||||
$unset?: OnlyFieldsOfType<TSchema, any, "" | true | 1>;
|
||||
$pull?: PullOperator<TSchema>;
|
||||
$push?: PushOperator<TSchema>;
|
||||
};
|
||||
|
||||
type RootFilterOperators<TSchema> = {
|
||||
$and?: Filter<TSchema>[];
|
||||
$nor?: Filter<TSchema>[];
|
||||
$or?: Filter<TSchema>[];
|
||||
$text?: {
|
||||
$search: string;
|
||||
$language?: string;
|
||||
$caseSensitive?: boolean;
|
||||
$diacriticSensitive?: boolean;
|
||||
};
|
||||
$where?: string | ((this: TSchema) => boolean);
|
||||
$comment?: string | Document;
|
||||
};
|
||||
|
||||
type Condition<T> = AlternativeType<T> | FilterOperators<AlternativeType<T>>;
|
||||
|
||||
type AlternativeType<T> = T extends ReadonlyArray<infer U> ? T | RegExpOrString<U> : RegExpOrString<T>;
|
||||
|
||||
type RegExpOrString<T> = T extends string ? BSONRegExp | RegExp | T : T;
|
||||
|
||||
type FilterOperators<TValue> = {
|
||||
$eq?: TValue;
|
||||
$gt?: TValue;
|
||||
$gte?: TValue;
|
||||
$in?: ReadonlyArray<TValue>;
|
||||
$lt?: TValue;
|
||||
$lte?: TValue;
|
||||
$ne?: TValue;
|
||||
$nin?: ReadonlyArray<TValue>;
|
||||
$not?: TValue extends string ? FilterOperators<TValue> | RegExp : FilterOperators<TValue>;
|
||||
/**
|
||||
* When `true`, `$exists` matches the documents that contain the field,
|
||||
* including documents where the field value is null.
|
||||
*/
|
||||
$exists?: boolean;
|
||||
$type?: BSONType | BSONTypeAlias;
|
||||
$expr?: Record<string, any>;
|
||||
$jsonSchema?: Record<string, any>;
|
||||
$mod?: TValue extends number ? [number, number] : never;
|
||||
$regex?: TValue extends string ? RegExp | string : never;
|
||||
$options?: TValue extends string ? string : never;
|
||||
$geoIntersects?: {
|
||||
$geometry: Document;
|
||||
};
|
||||
$geoWithin?: Document;
|
||||
$near?: Document;
|
||||
$nearSphere?: Document;
|
||||
$maxDistance?: number;
|
||||
$all?: ReadonlyArray<any>;
|
||||
$elemMatch?: Document;
|
||||
$size?: TValue extends ReadonlyArray<any> ? number : never;
|
||||
$bitsAllClear?: BitwiseFilter;
|
||||
$bitsAllSet?: BitwiseFilter;
|
||||
$bitsAnyClear?: BitwiseFilter;
|
||||
$bitsAnySet?: BitwiseFilter;
|
||||
$rand?: Record<string, never>;
|
||||
};
|
||||
|
||||
type BSONTypeAlias = keyof typeof BSONType;
|
||||
|
||||
type BitwiseFilter = number | ReadonlyArray<number>;
|
||||
|
||||
type OnlyFieldsOfType<TSchema, FieldType = any, AssignableType = FieldType> = IsAny<
|
||||
TSchema[keyof TSchema],
|
||||
Record<string, FieldType>,
|
||||
AcceptedFields<TSchema, FieldType, AssignableType> &
|
||||
NotAcceptedFields<TSchema, FieldType> &
|
||||
Record<string, AssignableType>
|
||||
>;
|
||||
|
||||
type MatchKeysAndValues<TSchema> = Readonly<Partial<TSchema>>;
|
||||
|
||||
type MatchKeysToFunctionValues<TSchema> = {
|
||||
readonly [key in keyof TSchema]?: (this: TSchema, value: TSchema[key]) => TSchema[key];
|
||||
};
|
||||
|
||||
type PullOperator<TSchema> = ({
|
||||
readonly [key in KeysOfAType<TSchema, ReadonlyArray<any>>]?:
|
||||
| Partial<Flatten<TSchema[key]>>
|
||||
| FilterOperations<Flatten<TSchema[key]>>;
|
||||
} & NotAcceptedFields<TSchema, ReadonlyArray<any>>) & {
|
||||
readonly [key: string]: FilterOperators<any> | any;
|
||||
};
|
||||
|
||||
type PushOperator<TSchema> = ({
|
||||
readonly [key in KeysOfAType<TSchema, ReadonlyArray<any>>]?:
|
||||
| Flatten<TSchema[key]>
|
||||
| ArrayOperator<Array<Flatten<TSchema[key]>>>;
|
||||
} & NotAcceptedFields<TSchema, ReadonlyArray<any>>) & {
|
||||
readonly [key: string]: ArrayOperator<any> | any;
|
||||
};
|
||||
|
||||
type KeysOfAType<TSchema, Type> = {
|
||||
[key in keyof TSchema]: NonNullable<TSchema[key]> extends Type ? key : never;
|
||||
}[keyof TSchema];
|
||||
|
||||
type AcceptedFields<TSchema, FieldType, AssignableType> = {
|
||||
readonly [key in KeysOfAType<TSchema, FieldType>]?: AssignableType;
|
||||
};
|
||||
|
||||
type NotAcceptedFields<TSchema, FieldType> = {
|
||||
readonly [key in KeysOfOtherType<TSchema, FieldType>]?: never;
|
||||
};
|
||||
|
||||
type Flatten<Type> = Type extends ReadonlyArray<infer Item> ? Item : Type;
|
||||
|
||||
type IsAny<Type, ResultIfAny, ResultIfNotAny> = true extends false & Type ? ResultIfAny : ResultIfNotAny;
|
||||
|
||||
type FilterOperations<T> = T extends Record<string, any>
|
||||
? {
|
||||
[key in keyof T]?: FilterOperators<T[key]>;
|
||||
}
|
||||
: FilterOperators<T>;
|
||||
|
||||
type ArrayOperator<Type> = {
|
||||
$each?: Array<Flatten<Type>>;
|
||||
$slice?: number;
|
||||
$position?: number;
|
||||
$sort?: Sort;
|
||||
};
|
||||
|
||||
type Sort =
|
||||
| string
|
||||
| Exclude<
|
||||
SortDirection,
|
||||
{
|
||||
$meta: string;
|
||||
}
|
||||
>
|
||||
| string[]
|
||||
| {
|
||||
[key: string]: SortDirection;
|
||||
}
|
||||
| Map<string, SortDirection>
|
||||
| [string, SortDirection][]
|
||||
| [string, SortDirection];
|
||||
|
||||
type SortDirection =
|
||||
| 1
|
||||
| -1
|
||||
| "asc"
|
||||
| "desc"
|
||||
| "ascending"
|
||||
| "descending"
|
||||
| {
|
||||
$meta: string;
|
||||
};
|
||||
|
||||
type KeysOfOtherType<TSchema, Type> = {
|
||||
[key in keyof TSchema]: NonNullable<TSchema[key]> extends Type ? never : key;
|
||||
}[keyof TSchema];
|
||||
4
src/index.ts
Normal file
4
src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./Collection.js";
|
||||
export * from "./Databases/mod.js";
|
||||
export * from "./Storage/mod.js";
|
||||
export type { Document, Filter } from "./Types.js";
|
||||
Reference in New Issue
Block a user