From ed15a0eb27ee715da40708ad2dfa11876492d567 Mon Sep 17 00:00:00 2001 From: kodemon Date: Mon, 5 Jan 2026 04:42:46 +0100 Subject: [PATCH] refactor: simplify and add memory indexing --- deno.json | 2 +- deno.lock | 63 ++---- package.json | 6 +- src/broadcast.ts | 11 +- src/clone.ts | 3 - src/collection.ts | 206 ++++++++--------- src/databases/indexeddb/cache.ts | 16 +- src/databases/indexeddb/database.ts | 43 ++-- src/databases/indexeddb/storage.ts | 214 ++++-------------- src/databases/memory/database.ts | 53 +++-- src/databases/memory/storage.ts | 147 ++++-------- src/databases/memory/tests/storage.test.ts | 29 +++ src/databases/mod.ts | 2 - src/databases/observer/storage.ts | 147 ------------ src/databases/registrars.ts | 23 -- src/hash.ts | 10 +- src/index/manager.ts | 117 ++++++++++ src/index/primary.ts | 24 ++ src/index/shared.ts | 46 ++++ src/index/unique.ts | 20 ++ src/logger.ts | 6 +- src/mod.ts | 7 +- src/observe/is-match.ts | 11 +- src/observe/observe-one.ts | 41 ++-- src/observe/observe.ts | 79 ++++--- src/observe/store.ts | 83 ------- src/primary-key.ts | 8 - src/registrars.ts | 18 ++ src/storage.ts | 231 +++++++++++++++++++ src/storage/collections.ts | 33 --- src/storage/errors.ts | 36 --- src/storage/mod.ts | 5 - src/storage/operators/insert.ts | 4 - src/storage/operators/update.ts | 4 - src/storage/storage.ts | 246 --------------------- src/types.ts | 178 +-------------- tests/users.mock.ts | 10 +- 37 files changed, 854 insertions(+), 1328 deletions(-) delete mode 100644 src/clone.ts create mode 100644 src/databases/memory/tests/storage.test.ts delete mode 100644 src/databases/mod.ts delete mode 100644 src/databases/observer/storage.ts delete mode 100644 src/databases/registrars.ts create mode 100644 src/index/manager.ts create mode 100644 src/index/primary.ts create mode 100644 src/index/shared.ts create mode 100644 src/index/unique.ts delete mode 100644 src/observe/store.ts delete mode 100644 src/primary-key.ts create mode 100644 src/registrars.ts create mode 100644 src/storage.ts delete mode 100644 src/storage/collections.ts delete mode 100644 src/storage/errors.ts delete mode 100644 src/storage/mod.ts delete mode 100644 src/storage/operators/insert.ts delete mode 100644 src/storage/operators/update.ts delete mode 100644 src/storage/storage.ts diff --git a/deno.json b/deno.json index 1fa8492..fb475a3 100644 --- a/deno.json +++ b/deno.json @@ -24,7 +24,7 @@ }, "test": { - "command": "deno test --allow-all", + "command": "deno test --allow-all ./src", "description": "Run all tests using Deno’s built-in test runner." }, diff --git a/deno.lock b/deno.lock index 3f4d592..14c69b2 100644 --- a/deno.lock +++ b/deno.lock @@ -1,21 +1,17 @@ { "version": "5", "specifiers": { - "npm:@biomejs/biome@*": "2.3.10", "npm:@biomejs/biome@2.3.10": "2.3.10", "npm:@jsr/std__assert@1": "1.0.16", "npm:@jsr/std__async@1": "1.0.16", "npm:@jsr/std__testing@1": "1.0.16", + "npm:@jsr/valkyr__event-emitter@1.0.1": "1.0.1", "npm:@jsr/valkyr__testcontainers@2": "2.0.2", - "npm:bson@7.0.0": "7.0.0", - "npm:dot-prop@10.1.0": "10.1.0", "npm:expect@30.2.0": "30.2.0", "npm:fake-indexeddb@6.2.5": "6.2.5", - "npm:fast-equals@6.0.0": "6.0.0", "npm:idb@8.0.3": "8.0.3", "npm:mingo@7.1.1": "7.1.1", - "npm:rfdc@1.4.1": "1.4.1", - "npm:rxjs@7.8.2": "7.8.2", + "npm:sorted-btree@2.1.0": "2.1.0", "npm:zod@4.3.4": "4.3.4" }, "npm": { @@ -174,6 +170,13 @@ ], "tarball": "https://npm.jsr.io/~/11/@jsr/std__testing/1.0.16.tgz" }, + "@jsr/valkyr__event-emitter@1.0.1": { + "integrity": "sha512-mre5tWJddz8LylSQWuLOw3zgIxd2JmhGRV46jKXNPCGzY2NKJwGGT9H7SBw36RV4dW7jnnH2U1aCJkh8IS/pzA==", + "dependencies": [ + "eventemitter3" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__event-emitter/1.0.1.tgz" + }, "@jsr/valkyr__testcontainers@2.0.2": { "integrity": "sha512-YnmfraYFr3msoUGrIFeElm03nbQqXOaPu0QUT6JI3w6/mIYpVfzPxghkB7gn2RIc81QgrqjwKJE/AL3dltlR1w==", "dependencies": [ @@ -254,9 +257,6 @@ "bson@6.10.4": { "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==" }, - "bson@7.0.0": { - "integrity": "sha512-Kwc6Wh4lQ5OmkqqKhYGKIuELXl+EPYSCObVE6bWsp1T/cGkOCBN0I8wF/T44BiuhHyNi1mmKVPXk60d41xZ7kw==" - }, "chalk@4.1.2": { "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dependencies": [ @@ -276,15 +276,12 @@ "color-name@1.1.4": { "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, - "dot-prop@10.1.0": { - "integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==", - "dependencies": [ - "type-fest" - ] - }, "escape-string-regexp@2.0.0": { "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==" }, + "eventemitter3@5.0.1": { + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, "expect@30.2.0": { "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", "dependencies": [ @@ -299,9 +296,6 @@ "fake-indexeddb@6.2.5": { "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==" }, - "fast-equals@6.0.0": { - "integrity": "sha512-PFhhIGgdM79r5Uztdj9Zb6Tt1zKafqVfdMGwVca1z5z6fbX7DmsySSuJd8HiP6I1j505DCS83cLxo5rmSNeVEA==" - }, "fill-range@7.1.1": { "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dependencies": [ @@ -401,7 +395,7 @@ "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==", "dependencies": [ "@mongodb-js/saslprep", - "bson@6.10.4", + "bson", "mongodb-connection-string-url" ] }, @@ -431,18 +425,12 @@ "react-is@18.3.1": { "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" }, - "rfdc@1.4.1": { - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" - }, - "rxjs@7.8.2": { - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dependencies": [ - "tslib" - ] - }, "slash@3.0.0": { "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==" }, + "sorted-btree@2.1.0": { + "integrity": "sha512-AtYXy3lL+5jrATpbymC2bM8anN/3maLkmVCd94MzypnKjokfCid/zeS3rvXedv7W6ffSfqKIGdz3UaJPWRBZ0g==" + }, "sparse-bitfield@3.0.3": { "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", "dependencies": [ @@ -461,9 +449,6 @@ "has-flag" ] }, - "tagged-tag@1.0.0": { - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==" - }, "to-regex-range@5.0.1": { "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dependencies": [ @@ -476,15 +461,6 @@ "punycode" ] }, - "tslib@2.8.1": { - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - }, - "type-fest@5.3.1": { - "integrity": "sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg==", - "dependencies": [ - "tagged-tag" - ] - }, "undici-types@7.10.0": { "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" }, @@ -509,16 +485,13 @@ "npm:@jsr/std__assert@1", "npm:@jsr/std__async@1", "npm:@jsr/std__testing@1", + "npm:@jsr/valkyr__event-emitter@1.0.1", "npm:@jsr/valkyr__testcontainers@2", - "npm:bson@7.0.0", - "npm:dot-prop@10.1.0", "npm:expect@30.2.0", "npm:fake-indexeddb@6.2.5", - "npm:fast-equals@6.0.0", "npm:idb@8.0.3", "npm:mingo@7.1.1", - "npm:rfdc@1.4.1", - "npm:rxjs@7.8.2", + "npm:sorted-btree@2.1.0", "npm:zod@4.3.4" ] } diff --git a/package.json b/package.json index 821abdd..c2ec5a4 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,8 @@ { "dependencies": { - "bson": "7.0.0", - "dot-prop": "10.1.0", - "fast-equals": "6.0.0", + "@valkyr/event-emitter": "npm:@jsr/valkyr__event-emitter@1.0.1", "idb": "8.0.3", "mingo": "7.1.1", - "rfdc": "1.4.1", - "rxjs": "7.8.2", "zod": "4.3.4" }, "devDependencies": { diff --git a/src/broadcast.ts b/src/broadcast.ts index 9e744df..d55b407 100644 --- a/src/broadcast.ts +++ b/src/broadcast.ts @@ -1,4 +1,4 @@ -import type { AnyObject } from "mingo/types"; +import type { AnyDocument } from "./types.ts"; export const BroadcastChannel = globalThis.BroadcastChannel ?? @@ -11,13 +11,8 @@ export const BroadcastChannel = export type StorageBroadcast = | { name: string; - type: "insertOne" | "updateOne"; - data: AnyObject; - } - | { - name: string; - type: "insertMany" | "updateMany" | "remove"; - data: AnyObject[]; + type: "insert" | "update" | "remove"; + data: AnyDocument[]; } | { name: string; diff --git a/src/clone.ts b/src/clone.ts deleted file mode 100644 index 2135b54..0000000 --- a/src/clone.ts +++ /dev/null @@ -1,3 +0,0 @@ -import makeClone from "rfdc"; - -export const clone = makeClone(); diff --git a/src/collection.ts b/src/collection.ts index f6ec816..a111939 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -1,11 +1,12 @@ +import type { Subscription } from "@valkyr/event-emitter"; import type { AnyObject, Criteria } from "mingo/types"; import type { Modifier } from "mingo/updater"; -import { Observable, type Subject, type Subscription } from "rxjs"; -import type z from "zod"; import type { ZodObject, ZodRawShape } from "zod"; +import z from "zod"; import { observe, observeOne } from "./observe/mod.ts"; -import type { ChangeEvent, InsertResult, QueryOptions, Storage, UpdateResult } from "./storage/mod.ts"; +import type { Index } from "./registrars.ts"; +import type { ChangeEvent, QueryOptions, Storage, UpdateResult } from "./storage.ts"; import type { AnyDocument } from "./types.ts"; /* @@ -16,23 +17,54 @@ import type { AnyDocument } from "./types.ts"; export class Collection< TOptions extends AnyCollectionOptions = AnyCollectionOptions, - TAdapter extends Storage = TOptions["adapter"], - TPrimaryKey extends string = TOptions["primaryKey"], + TStorage extends Storage = TOptions["storage"], TSchema extends AnyDocument = z.output>, > { declare readonly $schema: TSchema; - constructor(readonly options: TOptions) {} + readonly #schema: ZodObject; + readonly #pkey: string | number; - get observable(): { - change: Subject; - flush: Subject; - } { - return this.storage.observable; + constructor(readonly options: TOptions) { + this.#schema = z.strictObject(options.schema); + this.#pkey = this.primaryKey; } - get storage(): TAdapter { - return this.options.adapter; + get name(): string { + return this.options.name; + } + + get storage(): TStorage { + return this.options.storage; + } + + get schema(): TOptions["schema"] { + return this.options.schema; + } + + get primaryKey(): string { + for (const index of this.options.indexes ?? []) { + if (index[1]?.primary === true) { + return index[0] as string; + } + } + throw new Error(`Collection '${this.name}' is missing required primary key assignment.`); + } + + /* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + + getPrimaryKeyValue(document: AnyDocument): string | number { + const id = document[this.#pkey]; + if (id === undefined || typeof id !== "string") { + throw new Error( + `Primary Key: Missing primary key '${this.#pkey}' on given document: ${JSON.stringify(document, null, 2)}`, + ); + } + return id; } /* @@ -41,73 +73,20 @@ export class Collection< |-------------------------------------------------------------------------------- */ - async insertOne(values: TSchema | Omit): Promise { - return this.storage.resolve().then((storage) => - storage.insertOne({ - collection: this.options.name, - pkey: this.options.primaryKey, - values, - }), - ); + async insert(documents: TSchema[]): Promise { + return this.storage.resolve().then((storage) => storage.insert(documents)); } - async insertMany(values: (TSchema | Omit)[]): Promise { - return this.storage.resolve().then((storage) => - storage.insertMany({ - collection: this.options.name, - pkey: this.options.primaryKey, - values, - }), - ); - } - - async updateOne( + async update( condition: Criteria, modifier: Modifier, arrayFilters?: AnyObject[], ): Promise { - return this.storage.resolve().then((storage) => - storage.updateOne({ - collection: this.options.name, - pkey: this.options.primaryKey, - condition, - modifier, - arrayFilters, - }), - ); - } - - async updateMany( - condition: Criteria, - modifier: Modifier, - arrayFilters?: AnyObject[], - ): Promise { - return this.storage.resolve().then((storage) => - storage.updateMany({ - collection: this.options.name, - pkey: this.options.primaryKey, - condition, - modifier, - arrayFilters, - }), - ); - } - - async replaceOne(condition: Criteria, document: TSchema): Promise { - return this.storage.resolve().then((storage) => - storage.replace({ - collection: this.options.name, - pkey: this.options.primaryKey, - condition, - document, - }), - ); + return this.storage.resolve().then((storage) => storage.update(condition, modifier, arrayFilters)); } async remove(condition: Criteria): Promise { - return this.storage - .resolve() - .then((storage) => storage.remove({ collection: this.options.name, pkey: this.options.primaryKey, condition })); + return this.storage.resolve().then((storage) => storage.remove(condition)); } /* @@ -128,28 +107,9 @@ export class Collection< ): Subscription; subscribe(condition: Criteria = {}, options?: QueryOptions, next?: (...args: any[]) => void): Subscription { if (options?.limit === 1) { - return this.#observeOne(condition).subscribe({ next }); + return observeOne(this as any, condition, (values) => next?.(values as any)); } - return this.#observe(condition, options).subscribe({ - next: (value: [TSchema[], TSchema[], ChangeEvent["type"]]) => next?.(...value), - }); - } - - #observe( - filter: Criteria = {}, - options?: QueryOptions, - ): Observable<[TSchema[], TSchema[], ChangeEvent["type"]]> { - return new Observable<[TSchema[], TSchema[], ChangeEvent["type"]]>((subscriber) => { - return observe(this as any, filter, options, (values, changed, type) => - subscriber.next([values, changed, type] as any), - ); - }); - } - - #observeOne(filter: Criteria = {}): Observable { - return new Observable((subscriber) => { - return observeOne(this as any, filter, (values) => subscriber.next(values as any)); - }); + return observe(this as any, condition, options, (values, changed, type) => next?.(values, changed, type)); } /* @@ -158,29 +118,26 @@ export class Collection< |-------------------------------------------------------------------------------- */ - /** - * Retrieve a record by the document 'id' key. - */ - async findById(id: string): Promise { - return this.storage.resolve().then((storage) => storage.findById({ collection: this.options.name, 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(condition: Criteria = {}, options?: QueryOptions): Promise { - return this.find(condition, options).then(([document]) => document); + async findOne(condition: Criteria = {}, options: QueryOptions = {}): Promise { + return this.findMany(condition, { ...options, limit: 1 }).then(([document]) => document); } /** * Performs a mingo filter search over the collection data and returns any * documents matching the provided filter and options. */ - async find(condition: Criteria = {}, options?: QueryOptions): Promise { + async findMany(condition: Criteria = {}, options?: QueryOptions): Promise { return this.storage .resolve() - .then((storage) => storage.find({ collection: this.options.name, condition, options })); + .then((storage) => + storage + .find(condition, options) + .then((documents) => documents.map((document) => this.#schema.parse(document) as TSchema)), + ); } /** @@ -200,6 +157,20 @@ export class Collection< storage.flush(); }); } + + /* + |-------------------------------------------------------------------------------- + | Event Handlers + |-------------------------------------------------------------------------------- + */ + + onFlush(cb: () => void) { + return this.storage.event.subscribe("flush", cb); + } + + onChange(cb: (event: ChangeEvent) => void) { + return this.storage.event.subscribe("change", cb); + } } /* @@ -214,7 +185,6 @@ export type SubscriptionOptions = { range?: QueryOptions["range"]; offset?: QueryOptions["offset"]; limit?: QueryOptions["limit"]; - index?: QueryOptions["index"]; }; export type SubscribeToSingle = QueryOptions & { @@ -225,16 +195,26 @@ export type SubscribeToMany = QueryOptions & { limit?: number; }; -type AnyCollectionOptions = CollectionOptions; +type AnyCollectionOptions = CollectionOptions; -type CollectionOptions< - TName extends string, - TAdapter extends Storage, - TPrimaryKey extends string | number | symbol, - TSchema extends ZodRawShape, -> = { +type CollectionOptions = { + /** + * Name of the collection. + */ name: TName; - adapter: TAdapter; - primaryKey: TPrimaryKey; + + /** + * Storage adapter used to persist the collection documents. + */ + storage: TStorage; + + /** + * Schema definition of the document stored for the collection. + */ schema: TSchema; + + /** + * List of custom indexes for the collection. + */ + indexes: Index[]; }; diff --git a/src/databases/indexeddb/cache.ts b/src/databases/indexeddb/cache.ts index 452e2f4..f43f4b2 100644 --- a/src/databases/indexeddb/cache.ts +++ b/src/databases/indexeddb/cache.ts @@ -1,13 +1,15 @@ -import { hashCodeQuery } from "../../hash.ts"; -import type { QueryOptions } from "../../storage/mod.ts"; -import type { Document, Filter } from "../../types.ts"; +import type { Criteria } from "mingo/types"; -export class IndexedDBCache { +import { hashCodeQuery } from "../../hash.ts"; +import type { QueryOptions } from "../../storage.ts"; +import type { AnyDocument } from "../../types.ts"; + +export class IndexedDBCache { readonly #cache = new Map(); readonly #documents = new Map(); - hash(filter: Filter, options: QueryOptions): number { - return hashCodeQuery(filter, options); + hash(condition: Criteria, options: QueryOptions = {}): number { + return hashCodeQuery(condition, options); } set(hashCode: number, documents: TSchema[]) { @@ -23,7 +25,7 @@ export class IndexedDBCache { get(hashCode: number): TSchema[] | undefined { const ids = this.#cache.get(hashCode); if (ids !== undefined) { - return ids.map((id) => this.#documents.get(id) as TSchema); + return ids.map((id) => this.#documents.get(id)).filter((document) => document !== undefined); } } diff --git a/src/databases/indexeddb/database.ts b/src/databases/indexeddb/database.ts index 82d298c..041eed9 100644 --- a/src/databases/indexeddb/database.ts +++ b/src/databases/indexeddb/database.ts @@ -2,30 +2,33 @@ import { type IDBPDatabase, openDB } from "idb"; import { Collection } from "../../collection.ts"; import type { DBLogger } from "../../logger.ts"; -import type { Document } from "../../types.ts"; -import type { Registrars } from "../registrars.ts"; +import type { Index, Registrars } from "../../registrars.ts"; import { IndexedDBStorage } from "./storage.ts"; -export class IndexedDB> { - readonly #collections = new Map>(); +export class IndexedDB { + readonly #collections = new Map(); readonly #db: Promise>; - constructor(readonly options: Options) { + constructor(readonly options: TOptions) { this.#db = openDB(options.name, options.version ?? 1, { upgrade: (db: IDBPDatabase) => { - for (const { name, primaryKey = "id", indexes = [] } of options.registrars) { - const store = db.createObjectStore(name as string, { keyPath: primaryKey }); - store.createIndex(primaryKey, primaryKey, { unique: true }); + for (const { name, indexes = [] } of options.registrars) { + const store = db.createObjectStore(name); for (const [keyPath, options] of indexes) { store.createIndex(keyPath, keyPath, options); } } }, }); - for (const { name, primaryKey = "id" } of options.registrars) { + for (const { name, schema, indexes } of options.registrars) { this.#collections.set( name, - new Collection(name, new IndexedDBStorage(name, primaryKey, this.#db, options.log ?? log)), + new Collection({ + name, + storage: new IndexedDBStorage(name, indexes, this.#db, options.log), + schema, + indexes, + }), ); } } @@ -36,7 +39,17 @@ export class IndexedDB> { |-------------------------------------------------------------------------------- */ - collection(name: Name) { + collection< + TName extends TOptions["registrars"][number]["name"], + TSchema = Extract["schema"], + >( + name: TName, + ): Collection<{ + name: TName; + storage: IndexedDBStorage; + schema: TSchema; + indexes: Index[]; + }> { const collection = this.#collections.get(name); if (collection === undefined) { throw new Error(`Collection '${name as string}' not found`); @@ -65,13 +78,9 @@ export class IndexedDB> { } } -function log() {} - -type StringRecord = { [x: string]: TCollections }; - -type Options = { +type IndexedDBOptions = Array> = { name: string; + registrars: TRegistrars; version?: number; - registrars: Registrars[]; log?: DBLogger; }; diff --git a/src/databases/indexeddb/storage.ts b/src/databases/indexeddb/storage.ts index c5cf751..48692e0 100644 --- a/src/databases/indexeddb/storage.ts +++ b/src/databases/indexeddb/storage.ts @@ -1,30 +1,18 @@ import type { IDBPDatabase } from "idb"; import { Query, update } from "mingo"; -import type { Criteria, Options } from "mingo/types"; -import type { CloneMode, Modifier } from "mingo/updater"; +import type { Criteria } from "mingo/types"; +import type { Modifier } from "mingo/updater"; -import { type DBLogger, InsertLog, QueryLog, RemoveLog, ReplaceLog, UpdateLog } from "../../logger.ts"; -import { getDocumentWithPrimaryKey } from "../../primary-key.ts"; -import { DuplicateDocumentError } from "../../storage/errors.ts"; -import { - getInsertManyResult, - getInsertOneResult, - type InsertManyResult, - type InsertOneResult, -} from "../../storage/operators/insert.ts"; -import { RemoveResult } from "../../storage/operators/remove.ts"; -import { UpdateResult } from "../../storage/operators/update.ts"; -import { addOptions, type Index, type QueryOptions, Storage } from "../../storage/storage.ts"; -import type { Document, Filter } from "../../types.ts"; +import { type DBLogger, InsertLog, QueryLog, RemoveLog, UpdateLog } from "../../logger.ts"; +import type { Index } from "../../registrars.ts"; +import { addOptions, type QueryOptions, Storage, type UpdateResult } from "../../storage.ts"; +import type { AnyDocument } from "../../types.ts"; import { IndexedDBCache } from "./cache.ts"; const OBJECT_PROTOTYPE = Object.getPrototypeOf({}); const OBJECT_TAG = "[object Object]"; -export class IndexedDBStorage extends Storage< - TPrimaryKey, - TSchema -> { +export class IndexedDBStorage extends Storage { readonly #cache = new IndexedDBCache(); readonly #promise: Promise; @@ -33,11 +21,11 @@ export class IndexedDBStorage, - readonly log: DBLogger, + readonly log: DBLogger = function log() {}, ) { - super(name, primaryKey); + super(name, indexes); this.#promise = promise; } @@ -69,45 +57,17 @@ export class IndexedDBStorage): Promise { + async insert(documents: TSchema[]): Promise { const logger = new InsertLog(this.name); - const document = getDocumentWithPrimaryKey(this.primaryKey, values); - - if (await this.has(document[this.primaryKey])) { - 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(values: (TSchema | Omit)[]): Promise { - const logger = new InsertLog(this.name); - - const documents: TSchema[] = []; - const tx = this.db.transaction(this.name, "readwrite", { durability: "relaxed" }); - await Promise.all( - values.map((values) => { - const document = getDocumentWithPrimaryKey(this.primaryKey, values); - documents.push(document); - return tx.store.add(document); - }), - ); + await Promise.all(documents.map((document) => tx.store.add(document))); await tx.done; - this.broadcast("insertMany", documents); + this.broadcast("insert", documents); this.#cache.flush(); this.log(logger.result()); - - return getInsertManyResult(documents); } /* @@ -116,28 +76,28 @@ export class IndexedDBStorage { - return this.db.getFromIndex(this.name, "id", id); + async getByIndex(index: string, value: string): Promise { + return this.db.getAllFromIndex(this.name, index, value); } - async find(filter: Filter, options: QueryOptions = {}): Promise { - const logger = new QueryLog(this.name, { filter, options }); + async find(condition: Criteria = {}, options?: QueryOptions): Promise { + const logger = new QueryLog(this.name, { condition, options }); - const hashCode = this.#cache.hash(filter, options); + const hashCode = this.#cache.hash(condition, 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 })); + const indexes = this.#resolveIndexes(condition); + let cursor = new Query(condition).find(await this.#getAll({ ...options, ...indexes })); if (options !== undefined) { cursor = addOptions(cursor, options); } - const documents = cursor.all() as TSchema[]; - this.#cache.set(this.#cache.hash(filter, options), documents); + const documents = cursor.all(); + this.#cache.set(this.#cache.hash(condition, options), documents); this.log(logger.result()); @@ -172,10 +132,7 @@ export class IndexedDBStorage, + async update( + condition: Criteria, modifier: Modifier, - arrayFilters?: Filter[], - condition?: Criteria, - options: { cloneMode?: CloneMode; queryOptions?: Partial } = { cloneMode: "deep" }, + arrayFilters?: TSchema[], ): Promise { - if (typeof filter.id === "string") { - return this.#update(filter.id, modifier, arrayFilters, condition, options); - } - const documents = await this.find(filter); - if (documents.length > 0) { - return this.#update(documents[0].id, modifier, arrayFilters, condition, options); - } - return new UpdateResult(0, 0); - } + const logger = new UpdateLog(this.name, { condition, modifier, arrayFilters }); - async updateMany( - filter: Filter, - modifier: Modifier, - arrayFilters?: Filter[], - condition?: Criteria, - options: { cloneMode?: CloneMode; queryOptions?: Partial } = { cloneMode: "deep" }, - ): Promise { - const logger = new UpdateLog(this.name, { filter, modifier, arrayFilters, condition, options }); - - const ids = await this.find(filter).then((data) => data.map((d) => d.id)); + const ids = await this.find(condition).then((data) => data.map((d) => d.id)); const documents: TSchema[] = []; let modifiedCount = 0; @@ -271,7 +192,7 @@ export class IndexedDBStorage 0) { modifiedCount += 1; documents.push(current); @@ -283,71 +204,12 @@ export class IndexedDBStorage, document: TSchema): Promise { - const logger = new ReplaceLog(this.name, document); - - const ids = await this.find(filter).then((data) => data.map((d) => d.id)); - - const documents: 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, - modifier: Modifier, - arrayFilters?: Filter[], - condition?: Criteria, - options: { cloneMode?: CloneMode; queryOptions?: Partial } = { cloneMode: "deep" }, - ): Promise { - const logger = new UpdateLog(this.name, { id, modifier }); - - 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 = await update(current, modifier, arrayFilters, condition, options); - if (modified.length > 0) { - await tx.store.put(current); - } - await tx.done; - - if (modified.length > 0) { - this.broadcast("updateOne", current); - this.log(logger.result()); - this.#cache.flush(); - return new UpdateResult(1, 1); - } - - return new UpdateResult(1); + return { matchedCount: ids.length, modifiedCount }; } /* @@ -356,10 +218,10 @@ export class IndexedDBStorage): Promise { - const logger = new RemoveLog(this.name, { filter }); + async remove(condition: Criteria): Promise { + const logger = new RemoveLog(this.name, { condition }); - const documents = await this.find(filter); + const documents = await this.find(condition); const tx = this.db.transaction(this.name, "readwrite"); await Promise.all(documents.map((data) => tx.store.delete(data.id))); @@ -370,7 +232,7 @@ export class IndexedDBStorage): Promise { - if (filter !== undefined) { - return (await this.find(filter)).length; + async count(condition: Criteria): Promise { + if (condition !== undefined) { + return (await this.find(condition)).length; } return this.db.count(this.name); } diff --git a/src/databases/memory/database.ts b/src/databases/memory/database.ts index b8446f7..2b64967 100644 --- a/src/databases/memory/database.ts +++ b/src/databases/memory/database.ts @@ -1,31 +1,49 @@ import { Collection } from "../../collection.ts"; -import type { Document } from "../../types.ts"; -import type { Registrars } from "../registrars.ts"; +import type { Index, Registrars } from "../../registrars.ts"; import { MemoryStorage } from "./storage.ts"; -type Options = { - name: string; - registrars: Registrars[]; -}; +export class MemoryDatabase { + readonly #collections = new Map(); -export class MemoryDatabase> { - readonly name: string; - readonly #collections = new Map>(); - - constructor(readonly options: Options) { - this.name = options.name; - for (const { name } of options.registrars) { - this.#collections.set(name, new Collection(name, new MemoryStorage(name))); + constructor(readonly options: TOptions) { + for (const { name, schema, indexes } of options.registrars) { + this.#collections.set( + name, + new Collection({ + name, + storage: new MemoryStorage(name, indexes), + schema, + indexes, + }), + ); } } + get name() { + return this.options.name; + } + + get registrars() { + return this.options.registrars; + } + /* |-------------------------------------------------------------------------------- | Fetchers |-------------------------------------------------------------------------------- */ - collection(name: Name): Collection { + collection< + TName extends TOptions["registrars"][number]["name"], + TSchema = Extract["schema"], + >( + name: TName, + ): Collection<{ + name: TName; + storage: MemoryStorage; + schema: TSchema; + indexes: Index[]; + }> { const collection = this.#collections.get(name); if (collection === undefined) { throw new Error(`Collection '${name as string}' not found`); @@ -45,3 +63,8 @@ export class MemoryDatabase> { } } } + +type MemoryDatabaseOptions = Array> = { + name: string; + registrars: TRegistrars; +}; diff --git a/src/databases/memory/storage.ts b/src/databases/memory/storage.ts index 0c23904..dfa8b74 100644 --- a/src/databases/memory/storage.ts +++ b/src/databases/memory/storage.ts @@ -1,155 +1,88 @@ import { Query, update } from "mingo"; -import type { AnyObject } from "mingo/types"; +import type { Criteria } from "mingo/types"; +import type { Modifier } from "mingo/updater"; -import { getDocumentWithPrimaryKey } from "../../primary-key.ts"; -import { Collections } from "../../storage/collections.ts"; -import type { UpdatePayload } from "../../storage/mod.ts"; -import type { InsertResult } from "../../storage/operators/insert.ts"; -import type { UpdateResult } from "../../storage/operators/update.ts"; -import { - addOptions, - type CountPayload, - type FindByIdPayload, - type FindPayload, - type InsertManyPayload, - type InsertOnePayload, - type RemovePayload, - type ReplacePayload, - Storage, -} from "../../storage/storage.ts"; +import { IndexManager, type IndexSpec } from "../../index/manager.ts"; +import type { UpdateResult } from "../../storage.ts"; +import { addOptions, type QueryOptions, Storage } from "../../storage.ts"; import type { AnyDocument } from "../../types.ts"; -export class MemoryStorage extends Storage { - readonly #collections = new Collections(); +export class MemoryStorage extends Storage { + readonly index: IndexManager; + + constructor(name: string, indexes: IndexSpec[]) { + super(name, indexes); + this.index = new IndexManager(indexes); + } + + get documents() { + return this.index.primary.tree; + } async resolve() { return this; } - async insertOne({ pkey, values, ...payload }: InsertOnePayload): Promise { - const collection = this.#collections.get(payload.collection); - - const document = getDocumentWithPrimaryKey(pkey, values); - if (collection.has(document[pkey])) { - return { insertCount: 0, insertIds: [] }; + async insert(documents: TSchema[]): Promise { + for (const document of documents) { + this.index.insert(document); } - - collection.set(document[pkey], document); - this.broadcast("insertOne", document); - - return { insertCount: 1, insertIds: [document[pkey]] }; + this.broadcast("insert", documents); } - async insertMany({ pkey, values, ...payload }: InsertManyPayload): Promise { - const collection = this.#collections.get(payload.collection); - - const documents: AnyDocument[] = []; - for (const insert of values) { - const document = getDocumentWithPrimaryKey(pkey, insert); - if (collection.has(document[pkey])) { - continue; - } - collection.set(document[pkey], document); - documents.push(document); - } - - if (documents.length > 0) { - this.broadcast("insertMany", documents); - } - - return { insertCount: documents.length, insertIds: documents.map((document) => document[pkey]) }; + async getByIndex(index: string, value: string): Promise { + return this.index.get(index)?.get(value) ?? []; } - async findById({ collection, id }: FindByIdPayload): Promise { - return this.#collections.get(collection).get(id); - } - - async find({ condition = {}, options, ...payload }: FindPayload): Promise { - let cursor = new Query(condition).find(this.#collections.documents(payload.collection)); + async find(condition: Criteria = {}, options?: QueryOptions): Promise { + let cursor = new Query(condition).find(this.documents); if (options !== undefined) { cursor = addOptions(cursor, options); } return cursor.all(); } - async updateOne({ pkey, condition, modifier, arrayFilters, ...payload }: UpdatePayload): Promise { - const collection = this.#collections.get(payload.collection); + async update( + condition: Criteria, + modifier: Modifier, + arrayFilters?: TSchema[], + ): Promise { + const documents: TSchema[] = []; let matchedCount = 0; let modifiedCount = 0; - for (const document of await this.find({ collection: payload.collection, condition, options: { limit: 1 } })) { - const modified = update(document, modifier, arrayFilters, undefined, { cloneMode: "deep" }); - if (modified.length > 0) { - collection.set(document[pkey], document); - this.broadcast("updateOne", document); - modifiedCount += 1; - } - matchedCount += 1; - } - return { matchedCount, modifiedCount }; - } - - async updateMany({ pkey, condition, modifier, arrayFilters, ...payload }: UpdatePayload): Promise { - const collection = this.#collections.get(payload.collection); - - const documents: AnyDocument[] = []; - - let matchedCount = 0; - let modifiedCount = 0; - - for (const document of await this.find({ collection: payload.collection, condition })) { + for (const document of await this.find(condition)) { matchedCount += 1; const modified = update(document, modifier, arrayFilters, undefined, { cloneMode: "deep" }); if (modified.length > 0) { modifiedCount += 1; documents.push(document); - collection.set(document[pkey], document); + this.documents.add(document); } } - this.broadcast("updateMany", documents); - - return { matchedCount, modifiedCount }; - } - - async replace({ pkey, condition, document, ...payload }: ReplacePayload): Promise { - const collection = this.#collections.get(payload.collection); - - let matchedCount = 0; - let modifiedCount = 0; - - const documents: AnyDocument[] = []; - for (const current of await this.find({ collection: payload.collection, condition })) { - matchedCount += 1; - modifiedCount += 1; - documents.push(document); - collection.set(current[pkey], document); + if (modifiedCount > 0) { + this.broadcast("update", documents); } - this.broadcast("updateMany", documents); - return { matchedCount, modifiedCount }; } - async remove({ pkey, condition, ...payload }: RemovePayload): Promise { - const collection = this.#collections.get(payload.collection); - - const documents = await this.find({ collection: payload.collection, condition }); + async remove(condition: Criteria): Promise { + const documents = await this.find(condition); for (const document of documents) { - collection.delete(document[pkey]); + this.documents.delete(document); } - this.broadcast("remove", documents); - return documents.length; } - async count({ collection, condition = {} }: CountPayload): Promise { - return new Query(condition).find(this.#collections.documents(collection)).all().length; + async count(condition: Criteria): Promise { + return new Query(condition).find(this.documents).all().length; } async flush(): Promise { - this.#collections.flush(); + this.documents.clear(); } } diff --git a/src/databases/memory/tests/storage.test.ts b/src/databases/memory/tests/storage.test.ts new file mode 100644 index 0000000..a04b52c --- /dev/null +++ b/src/databases/memory/tests/storage.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "expect"; + +import { MemoryStorage } from "../storage.ts"; + +/* + |-------------------------------------------------------------------------------- + | Unit Tests + |-------------------------------------------------------------------------------- + */ + +describe("Memory Storage", () => { + it("should insert new records", async () => { + const storage = new MemoryStorage("test", [["id", { primary: true }]]); + + const documents = [ + { + id: "abc", + foo: "bar", + }, + ]; + + await storage.insert(documents); + + console.log(storage); + + expect(storage.documents).toContain(documents); + }); +}); diff --git a/src/databases/mod.ts b/src/databases/mod.ts deleted file mode 100644 index 91f92fc..0000000 --- a/src/databases/mod.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./indexeddb/database.ts"; -export * from "./memory/database.ts"; diff --git a/src/databases/observer/storage.ts b/src/databases/observer/storage.ts deleted file mode 100644 index e7fb36a..0000000 --- a/src/databases/observer/storage.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Query, update } from "mingo"; -import type { Criteria, Options } from "mingo/types"; -import type { CloneMode, Modifier } from "mingo/updater"; - -import { getDocumentWithPrimaryKey } from "../../primary-key.ts"; -import { DuplicateDocumentError } from "../../storage/errors.ts"; -import type { InsertResult } from "../../storage/operators/insert.ts"; -import { UpdateResult } from "../../storage/operators/update.ts"; -import { addOptions, type QueryOptions, Storage } from "../../storage/storage.ts"; -import type { AnyDocument } from "../../types.ts"; - -export class ObserverStorage extends Storage { - readonly #documents = new Map(); - - async resolve() { - return this; - } - - async has(id: string): Promise { - return this.#documents.has(id); - } - - async insertOne(values: AnyDocument): Promise { - const document = getDocumentWithPrimaryKey(this.primaryKey, values); - if (await this.has(document[this.primaryKey])) { - throw new DuplicateDocumentError(document, this as any); - } - this.#documents.set(document[this.primaryKey], document); - return getInsertOneResult(document); - } - - async insertMany(list: TSchema[]): Promise { - const result: TSchema[] = []; - for (const values of list) { - const document = getDocumentWithPrimaryKey(this.primaryKey, values); - result.push(document); - this.#documents.set(document.id, document); - } - return getInsertManyResult(result); - } - - async findById(id: string): Promise { - return this.#documents.get(id); - } - - async find(filter?: Filter, options?: QueryOptions): Promise { - let cursor = new Query(filter ?? {}).find(Array.from(this.#documents.values())); - if (options !== undefined) { - cursor = addOptions(cursor, options); - } - return cursor.all(); - } - - async updateOne( - filter: Filter, - modifier: Modifier, - arrayFilters?: Filter[], - condition?: Criteria, - options: { cloneMode?: CloneMode; queryOptions?: Partial } = { cloneMode: "deep" }, - ): Promise { - const query = new Query(filter); - for (const document of Array.from(this.#documents.values())) { - if (query.test(document) === true) { - const modified = update(document, modifier, arrayFilters, condition, options); - if (modified.length > 0) { - 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, - modifier: Modifier, - arrayFilters?: Filter[], - condition?: Criteria, - options: { cloneMode?: CloneMode; queryOptions?: Partial } = { cloneMode: "deep" }, - ): Promise { - const query = new Query(filter); - - const documents: TSchema[] = []; - - let matchedCount = 0; - let modifiedCount = 0; - - for (const document of Array.from(this.#documents.values())) { - if (query.test(document) === true) { - matchedCount += 1; - const modified = update(document, modifier, arrayFilters, condition, options); - if (modified.length > 0) { - modifiedCount += 1; - documents.push(document); - this.#documents.set(document.id, document); - } - } - } - - this.broadcast("updateMany", documents); - - return new UpdateResult(matchedCount, modifiedCount); - } - - async replace(filter: Filter, document: TSchema): Promise { - const query = new Query(filter); - - const documents: 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): Promise { - 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): Promise { - return new Query(filter ?? {}).find(Array.from(this.#documents.values())).all().length; - } - - async flush(): Promise { - this.#documents.clear(); - } -} diff --git a/src/databases/registrars.ts b/src/databases/registrars.ts deleted file mode 100644 index 1740ad6..0000000 --- a/src/databases/registrars.ts +++ /dev/null @@ -1,23 +0,0 @@ -export type Registrars = { - /** - * Name of the collection. - */ - name: string; - - /** - * Set the primary key of the collection. - * Default: "id" - */ - primaryKey?: string; - - /** - * List of custom indexes for the collection. - */ - indexes?: Index[]; -}; - -type Index = [IndexKey, IndexOptions?]; - -type IndexKey = string; - -type IndexOptions = { unique: boolean }; diff --git a/src/hash.ts b/src/hash.ts index e955e86..1b82c0c 100644 --- a/src/hash.ts +++ b/src/hash.ts @@ -1,5 +1,11 @@ -export function hashCodeQuery(filter: unknown, options: unknown): number { - const value = JSON.stringify({ filter, options }); +/** + * Generate a number from the given condition and option combination. + * + * @param condition - Condition to hash. + * @param options - Options to hash. + */ +export function hashCodeQuery(condition: unknown, options: unknown): number { + const value = JSON.stringify({ condition, options }); let hash = 0; if (value.length === 0) { return hash; diff --git a/src/index/manager.ts b/src/index/manager.ts new file mode 100644 index 0000000..91e65c9 --- /dev/null +++ b/src/index/manager.ts @@ -0,0 +1,117 @@ +import type { Criteria } from "mingo/types"; + +import type { AnyDocument } from "../types.ts"; +import { PrimaryIndex } from "./primary.ts"; +import { SharedIndex } from "./shared.ts"; +import { UniqueIndex } from "./unique.ts"; + +export class IndexManager { + readonly primary: PrimaryIndex; + + readonly unique = new Map(); + readonly shared = new Map(); + + constructor(specs: IndexSpec[]) { + const primary = specs.find((spec) => spec.kind === "primary"); + if (primary === undefined) { + throw new Error("Primary index is required"); + } + this.primary = new PrimaryIndex(primary.field); + for (const spec of specs) { + switch (spec.kind) { + case "unique": { + this.unique.set(spec.field, new UniqueIndex()); + break; + } + case "shared": { + this.shared.set(spec.field, new SharedIndex()); + break; + } + } + } + } + + insert(document: TSchema) { + const pk = document[this.primary.key]; + for (const [field, index] of this.unique) { + index.insert(document[field], pk); + } + for (const [field, index] of this.shared) { + index.insert(document[field], pk); + } + this.primary.insert(pk, document); + } + + getByCondition(condition: Criteria): TSchema[] | undefined { + // const pks = new Set(); + // for (const key in condition) { + // if (this.indexes.includes(key)) { + // if (key === this.primaryKey) { + // pks.add(condition[key]); + // } else { + // const + // } + // } + // } + return []; + } + + getByPrimary(pk: string): TSchema | undefined { + return this.primary.get(pk); + } + + getByUnique(field: keyof TSchema, value: any): TSchema | undefined { + const pk = this.unique.get(field)?.lookup(value); + if (pk !== undefined) { + return this.primary.get(pk); + } + } + + getByIndex(field: keyof TSchema, value: any): TSchema[] { + if (this.unique.has(field)) { + const document = this.getByUnique(field, value); + if (document === undefined) { + this.unique.get(field)?.delete(value); + return []; + } + return [document]; + } + + const pks = this.shared.get(field)?.lookup(value); + if (pks === undefined) { + return []; + } + + const documents: TSchema[] = []; + for (const pk of pks) { + const document = this.primary.get(pk); + if (document === undefined) { + this.shared.get(field)?.delete(value, pk); + } else { + documents.push(document); + } + } + return documents; + } + + remove(pk: string) { + const document = this.primary.get(pk); + if (document === undefined) { + return; + } + for (const [field, index] of this.unique) { + index.delete(document[field]); + } + for (const [field, index] of this.shared) { + index.delete(document[field], pk); + } + this.primary.delete(pk); + } +} + +export type IndexSpec = { + field: string; + kind: IndexKind; +}; + +type IndexKind = "primary" | "unique" | "shared"; diff --git a/src/index/primary.ts b/src/index/primary.ts new file mode 100644 index 0000000..40ea39e --- /dev/null +++ b/src/index/primary.ts @@ -0,0 +1,24 @@ +import type { AnyDocument } from "../types.ts"; + +export type PrimaryKey = string; + +export class PrimaryIndex { + readonly #index = new Map(); + + constructor(readonly key: string) {} + + insert(pk: PrimaryKey, document: TSchema) { + if (this.#index.has(pk)) { + throw new Error(`Duplicate primary key: ${pk}`); + } + this.#index.set(pk, document); + } + + get(pk: PrimaryKey): TSchema | undefined { + return this.#index.get(pk); + } + + delete(pk: PrimaryKey) { + this.#index.delete(pk); + } +} diff --git a/src/index/shared.ts b/src/index/shared.ts new file mode 100644 index 0000000..b716006 --- /dev/null +++ b/src/index/shared.ts @@ -0,0 +1,46 @@ +import type { PrimaryKey } from "./primary.ts"; + +export class SharedIndex { + readonly #index = new Map>(); + + /** + * Add a value to a shared primary key index. + * + * @param value - Value to map the primary key to. + * @param pk - Primary key to add to the value set. + */ + insert(value: any, pk: PrimaryKey) { + let set = this.#index.get(value); + if (set === undefined) { + set = new Set(); + this.#index.set(value, set); + } + set.add(pk); + } + + /** + * Find a indexed primary key for the given value. + * + * @param value - Value to lookup a primary key for. + */ + lookup(value: any): Set { + return this.#index.get(value) ?? new Set(); + } + + /** + * Delete a primary key from a indexed value. + * + * @param value - Value to remove primary key from. + * @param pk - Primary key to remove. + */ + delete(value: any, pk: PrimaryKey) { + const set = this.#index.get(value); + if (set === undefined) { + return; + } + set.delete(pk); + if (set.size === 0) { + this.#index.delete(value); + } + } +} diff --git a/src/index/unique.ts b/src/index/unique.ts new file mode 100644 index 0000000..32ab81c --- /dev/null +++ b/src/index/unique.ts @@ -0,0 +1,20 @@ +import type { PrimaryKey } from "./primary.ts"; + +export class UniqueIndex { + readonly #index = new Map(); + + insert(value: any, pk: PrimaryKey) { + if (this.#index.has(value)) { + throw new Error(`Unique constraint violation: ${value}`); + } + this.#index.set(value, pk); + } + + lookup(value: any): PrimaryKey | undefined { + return this.#index.get(value); + } + + delete(value: any) { + this.#index.delete(value); + } +} diff --git a/src/logger.ts b/src/logger.ts index 24b6d9f..08731ec 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -40,10 +40,6 @@ 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; } @@ -67,4 +63,4 @@ export type DBLogEvent = { message?: string; }; -type DBLogEventType = InsertLog["type"] | UpdateLog["type"] | ReplaceLog["type"] | RemoveLog["type"] | QueryLog["type"]; +type DBLogEventType = InsertLog["type"] | UpdateLog["type"] | RemoveLog["type"] | QueryLog["type"]; diff --git a/src/mod.ts b/src/mod.ts index d50c4c9..406abaa 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,4 +1,5 @@ export * from "./collection.ts"; -export * from "./databases/mod.ts"; -export * from "./storage/mod.ts"; -export type { Document, Filter } from "./types.ts"; +export * from "./databases/indexeddb/database.ts"; +export * from "./databases/memory/database.ts"; +export * from "./storage.ts"; +export type { AnyDocument } from "./types.ts"; diff --git a/src/observe/is-match.ts b/src/observe/is-match.ts index c408e5d..1bf7614 100644 --- a/src/observe/is-match.ts +++ b/src/observe/is-match.ts @@ -1,10 +1,11 @@ import { Query } from "mingo"; +import type { Criteria } from "mingo/types"; -import type { Document, Filter, WithId } from "../types.ts"; +import type { AnyDocument } from "../types.ts"; -export function isMatch( - document: WithId, - filter?: Filter>, +export function isMatch( + document: TSchema, + condition?: Criteria, ): boolean { - return !filter || new Query(filter).test(document); + return condition === undefined || new Query(condition).test(document); } diff --git a/src/observe/observe-one.ts b/src/observe/observe-one.ts index 775324d..ec228dc 100644 --- a/src/observe/observe-one.ts +++ b/src/observe/observe-one.ts @@ -1,28 +1,31 @@ +import type { Subscription } from "@valkyr/event-emitter"; +import type { Criteria } from "mingo/types"; + import type { Collection } from "../collection.ts"; -import type { Document, Filter, WithId } from "../types.ts"; +import type { AnyDocument } from "../types.ts"; import { isMatch } from "./is-match.ts"; -export function observeOne( - collection: Collection, - filter: Filter>, - onChange: (document: Document | undefined) => void, -): { - unsubscribe: () => void; -} { - collection.findOne(filter).then(onChange); - - const subscription = collection.observable.change.subscribe(({ type, data }) => { +export function observeOne( + collection: TCollection, + condition: Criteria, + onChange: (document: AnyDocument | undefined) => void, +): Subscription { + collection.findOne(condition).then((document) => onChange(document)); + return collection.onChange(({ type, data }) => { switch (type) { - case "insertOne": - case "updateOne": { - if (isMatch(data, filter) === true) { - onChange(data); + case "insert": + case "update": { + for (const document of data) { + if (isMatch(document, condition) === true) { + onChange(document); + break; + } } break; } case "remove": { for (const document of data) { - if (isMatch(document, filter) === true) { + if (isMatch(document, condition) === true) { onChange(undefined); break; } @@ -31,10 +34,4 @@ export function observeOne( } } }); - - return { - unsubscribe: () => { - subscription.unsubscribe(); - }, - }; } diff --git a/src/observe/observe.ts b/src/observe/observe.ts index 560cef1..30858eb 100644 --- a/src/observe/observe.ts +++ b/src/observe/observe.ts @@ -1,55 +1,79 @@ +import type { Subscription } from "@valkyr/event-emitter"; import { Query } from "mingo"; -import type { AnyObject, Criteria } from "mingo/types"; +import type { Criteria } from "mingo/types"; import type { Collection } from "../collection.ts"; -import { addOptions, type ChangeEvent, type QueryOptions } from "../storage/mod.ts"; +import { addOptions, type ChangeEvent, type QueryOptions } from "../storage.ts"; import type { AnyDocument } from "../types.ts"; -import { Store } from "./store.ts"; +import { isMatch } from "./is-match.ts"; -export function observe( +export function observe( collection: TCollection, - condition: Criteria, + condition: Criteria, options: QueryOptions | undefined, - onChange: (documents: TSchema[], changed: TSchema[], type: ChangeEvent["type"]) => void, -): { - unsubscribe: () => void; -} { - const store = Store.create(); + onChange: (documents: AnyDocument[], changed: AnyDocument[], type: ChangeEvent["type"]) => void, +): Subscription { + const documents = new Map(); let debounce: any; - collection.find(condition, options).then(async (documents) => { - const resolved = await store.resolve(documents); - onChange(resolved, resolved, "insertMany"); + // ### Init + // Find the initial documents and send them to the change listener. + + collection.findMany(condition, options).then(async (documents) => { + onChange(documents, documents, "insert"); }); + // ### Subscriptions + const subscriptions = [ - collection.observable.flush.subscribe(() => { + collection.onFlush(() => { clearTimeout(debounce); - store.flush(); onChange([], [], "remove"); }), - collection.observable.change.subscribe(async ({ type, data }) => { - let changed: AnyObject[] = []; + collection.onChange(async ({ type, data }) => { + const changed: AnyDocument[] = []; switch (type) { - case "insertOne": - case "updateOne": { - changed = await store[type](data, condition); + case "insert": { + for (const document of data) { + if (isMatch(document, condition)) { + documents.set(collection.getPrimaryKeyValue(document), document); + changed.push(document); + } + } + break; + } + case "update": { + for (const document of data) { + const id = collection.getPrimaryKeyValue(document); + if (documents.has(id)) { + if (isMatch(document, condition)) { + documents.set(id, document); + } else { + documents.delete(id); + } + changed.push(document); + } else if (isMatch(document, condition)) { + documents.set(id, document); + changed.push(document); + } + } break; } - case "insertMany": - case "updateMany": case "remove": { - changed = await store[type](data, condition); + for (const document of data) { + if (isMatch(document, condition)) { + documents.delete(collection.getPrimaryKeyValue(document)); + changed.push(document); + } + } break; } } if (changed.length > 0) { clearTimeout(debounce); debounce = setTimeout(() => { - store.getDocuments().then((documents) => { - onChange(applyQueryOptions(documents, options), changed, type); - }); + onChange(applyQueryOptions(Array.from(documents.values()), options), changed, type); }, 0); } }), @@ -60,14 +84,13 @@ export function observe(documents), options).all(); + return addOptions(new Query({}).find(documents), options).all(); } return documents; } diff --git a/src/observe/store.ts b/src/observe/store.ts deleted file mode 100644 index 39ad9a7..0000000 --- a/src/observe/store.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { ObserverStorage } from "../databases/observer/storage.ts"; -import type { Storage } from "../storage/mod.ts"; -import type { AnyDocument } from "../types.ts"; -import { isMatch } from "./is-match.ts"; - -export class Store { - private constructor(private storage: Storage) {} - - static create() { - return new Store(new ObserverStorage(`observer[${crypto.randomUUID()}]`)); - } - - get destroy() { - return this.storage.destroy.bind(this.storage); - } - - async resolve(documents: AnyDocument[]): Promise { - await this.storage.insertMany(documents); - return this.getDocuments(); - } - - async getDocuments(): Promise { - return this.storage.find(); - } - - async insertMany(documents: AnyDocument[], filter: Filter): Promise { - const matched = []; - for (const document of documents) { - matched.push(...(await this.insertOne(document, filter))); - } - return matched; - } - - async insertOne(document: AnyDocument, filter: Filter): Promise { - if (isMatch(document, filter)) { - await this.storage.insertOne(document); - return [document]; - } - return []; - } - - async updateMany(documents: AnyDocument[], filter: Filter): Promise { - const matched = []; - for (const document of documents) { - matched.push(...(await this.updateOne(document, filter))); - } - return matched; - } - - async updateOne(document: AnyDocument, filter: Filter): Promise { - if (await this.storage.has(document.id)) { - await this.#updateOrRemove(document, filter); - return [document]; - } else if (isMatch(document, filter)) { - await this.storage.insertOne(document); - return [document]; - } - return []; - } - - async remove(documents: AnyDocument[]): Promise { - const matched = []; - for (const document of documents) { - if (isMatch(document, { id: document.id } as AnyDocument)) { - await this.storage.remove({ id: document.id } as AnyDocument); - matched.push(document); - } - } - return matched; - } - - async #updateOrRemove(document: AnyDocument, filter: Filter): Promise { - if (isMatch(document, filter)) { - await this.storage.replace({ id: document.id } as AnyDocument, document); - } else { - await this.storage.remove({ id: document.id } as AnyDocument); - } - } - - flush() { - this.storage.flush(); - } -} diff --git a/src/primary-key.ts b/src/primary-key.ts deleted file mode 100644 index 6de3d64..0000000 --- a/src/primary-key.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { AnyDocument } from "./types.ts"; - -export function getDocumentWithPrimaryKey(pkey: TPKey, document: AnyDocument): AnyDocument { - if (Object.hasOwn(document, pkey) === true) { - return document; - } - return { [pkey]: crypto.randomUUID(), ...document }; -} diff --git a/src/registrars.ts b/src/registrars.ts new file mode 100644 index 0000000..7f2f130 --- /dev/null +++ b/src/registrars.ts @@ -0,0 +1,18 @@ +import type { ZodRawShape } from "zod"; + +export type Registrars = { + /** + * Name of the collection. + */ + name: string; + + /** + * Schema definition of the documents stored in the collection. + */ + schema: TSchema; + + /** + * List of custom indexes for the collection. + */ + indexes: IndexSpec[]; +}; diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..69c2f7c --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,231 @@ +import { EventEmitter } from "@valkyr/event-emitter"; +import type { Cursor } from "mingo/cursor"; +import type { Criteria } from "mingo/types"; +import type { Modifier } from "mingo/updater"; + +import { BroadcastChannel, type StorageBroadcast } from "./broadcast.ts"; +import type { Index } from "./registrars.ts"; +import type { AnyDocument } from "./types.ts"; + +type StorageEvent = "change" | "flush"; + +export abstract class Storage { + readonly event = new EventEmitter(); + + status: Status = "loading"; + + readonly #channel: BroadcastChannel; + + constructor( + /** + * Name of the collection the storage is holding documents for. + */ + readonly name: string, + + /** + * List of indexes to optimize storage lookups. + */ + readonly indexes: Index[], + ) { + if (primaryIndexCount(indexes) !== 1) { + throw new Error("Storage is missing or has more than 1 defined primaryIndex"); + } + this.#channel = new BroadcastChannel(`@valkyr/db:${name}`); + this.#channel.onmessage = ({ data }: MessageEvent) => { + if (data.name !== this.name) { + return; + } + switch (data.type) { + case "flush": { + this.event.emit("flush"); + break; + } + default: { + this.event.emit("change", data); + break; + } + } + }; + } + + /* + |-------------------------------------------------------------------------------- + | Resolver + |-------------------------------------------------------------------------------- + */ + + abstract resolve(): Promise; + + /* + |-------------------------------------------------------------------------------- + | 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: "flush"): void; + broadcast(type: "insert" | "update" | "remove", data: TSchema[]): void; + broadcast(type: StorageBroadcast["type"], data?: TSchema[]): void { + switch (type) { + case "flush": { + this.event.emit("flush"); + break; + } + default: { + this.event.emit("change", { type, data }); + break; + } + } + this.#channel.postMessage({ name: this.name, type, data }); + } + + /* + |-------------------------------------------------------------------------------- + | Operations + |-------------------------------------------------------------------------------- + */ + + /** + * Add list of documents to the storage engine. + * + * @param documents - Documents to add. + */ + abstract insert(documents: TSchema[]): Promise; + + /** + * Retrieve a list of documents by a index value. + * + * @param index - Index path to lookup. + * @param value - Value to match against the path. + */ + abstract getByIndex(index: string, value: string): Promise; + + /** + * Retrieve a list of documents from the storage engine. + * + * @param condition - Mingo criteria to filter documents against. + * @param options - Additional query options. + */ + abstract find(condition?: Criteria, options?: QueryOptions): Promise; + + /** + * Update documents matching the given condition. + * + * @param condition - Mingo criteria to filter documents to update. + * @param modifier - Modifications to apply to the filtered documents. + * @param arrayFilters - Custom filter. + */ + abstract update( + condition: Criteria, + modifier: Modifier, + arrayFilters?: TSchema[], + ): Promise; + + /** + * Remove documents matching the given condition. + * + * @param condition - Mingo criteria to filter documents to remove. + */ + abstract remove(condition: Criteria): Promise; + + /** + * Get document count matching given condition. + * + * @param condition - Mingo criteria to count document against. + */ + abstract count(condition: Criteria): Promise; + + /** + * Remove all documents in the storage. + */ + abstract flush(): Promise; + + /* + |-------------------------------------------------------------------------------- + | Destructor + |-------------------------------------------------------------------------------- + */ + + destroy() { + this.#channel.close(); + } +} + +/* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + +export function addOptions( + cursor: Cursor, + options: QueryOptions, +): 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; +} + +function primaryIndexCount(indexes: Index[]): number { + let count = 0; + for (const [, options] of indexes) { + if (options?.primary === true) { + count += 1; + } + } + return count; +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type Status = "loading" | "ready"; + +export type ChangeEvent = { + type: "insert" | "update" | "remove"; + data: TSchema[]; +}; + +export type QueryOptions = { + sort?: { + [key: string]: 1 | -1; + }; + skip?: number; + range?: { + from: string; + to: string; + }; + offset?: { + value: string; + direction: 1 | -1; + }; + limit?: number; +}; + +export type UpdateResult = { + matchedCount: number; + modifiedCount: number; +}; diff --git a/src/storage/collections.ts b/src/storage/collections.ts deleted file mode 100644 index 4b15b5f..0000000 --- a/src/storage/collections.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { AnyObject } from "mingo/types"; - -import { CollectionNotFoundError } from "./errors.ts"; - -export class Collections { - #collections = new Map(); - - has(name: string): boolean { - return this.#collections.has(name); - } - - documents(name: string): AnyObject[] { - return Array.from(this.get(name).values()); - } - - get(name: string): Documents { - const collection = this.#collections.get(name); - if (collection === undefined) { - throw new CollectionNotFoundError(name); - } - return collection; - } - - delete(name: string): boolean { - return this.#collections.delete(name); - } - - flush() { - this.#collections.clear(); - } -} - -type Documents = Map; diff --git a/src/storage/errors.ts b/src/storage/errors.ts deleted file mode 100644 index 08b1171..0000000 --- a/src/storage/errors.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { AnyObject } from "mingo/types"; - -export class DuplicateDocumentError extends Error { - readonly type = "DuplicateDocumentError"; - - constructor( - readonly collection: string, - readonly document: AnyObject, - ) { - super(`Collection Insert Violation: Document '${document.id}' already exists in '${collection}' collection`); - } -} - -export class CollectionNotFoundError extends Error { - readonly type = "CollectionNotFoundError"; - - constructor(readonly collection: string) { - super(`Collection Retrieve Violation: Collection '${collection}' does not exist`); - } -} - -export class DocumentNotFoundError extends Error { - readonly type = "DocumentNotFoundError"; - - constructor(readonly criteria: AnyObject) { - 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`); - } -} diff --git a/src/storage/mod.ts b/src/storage/mod.ts deleted file mode 100644 index 91915d1..0000000 --- a/src/storage/mod.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./errors.ts"; -export * from "./operators/insert.ts"; -export * from "./operators/remove.ts"; -export * from "./operators/update.ts"; -export * from "./storage.ts"; diff --git a/src/storage/operators/insert.ts b/src/storage/operators/insert.ts deleted file mode 100644 index 69e8505..0000000 --- a/src/storage/operators/insert.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type InsertResult = { - insertCount: number; - insertIds: (string | number | symbol)[]; -}; diff --git a/src/storage/operators/update.ts b/src/storage/operators/update.ts deleted file mode 100644 index be0fa0c..0000000 --- a/src/storage/operators/update.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type UpdateResult = { - matchedCount: number; - modifiedCount: number; -}; diff --git a/src/storage/storage.ts b/src/storage/storage.ts deleted file mode 100644 index 2c6ff5f..0000000 --- a/src/storage/storage.ts +++ /dev/null @@ -1,246 +0,0 @@ -import type { Cursor } from "mingo/cursor"; -import type { AnyObject, Criteria } from "mingo/types"; -import type { Modifier } from "mingo/updater"; -import { Subject } from "rxjs"; - -import { BroadcastChannel, type StorageBroadcast } from "../broadcast.ts"; -import type { Prettify } from "../types.ts"; -import type { InsertResult } from "./operators/insert.ts"; -import type { UpdateResult } from "./operators/update.ts"; - -export abstract class Storage { - readonly observable: { - change: Subject; - flush: Subject; - } = { - change: new Subject(), - flush: new Subject(), - }; - - status: Status = "loading"; - - readonly #channel: BroadcastChannel; - - constructor(readonly name: string) { - this.#channel = new BroadcastChannel(`valkyr:db:${name}`); - this.#channel.onmessage = ({ data }: MessageEvent) => { - 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; - - /* - |-------------------------------------------------------------------------------- - | 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["type"], data?: AnyObject | AnyObject[]): 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 insertOne(payload: InsertOnePayload): Promise; - - abstract insertMany(payload: InsertManyPayload): Promise; - - abstract findById(payload: FindByIdPayload): Promise; - - abstract find(payload: FindPayload): Promise; - - abstract updateOne(payload: UpdatePayload): Promise; - - abstract updateMany(payload: UpdatePayload): Promise; - - abstract replace(payload: ReplacePayload): Promise; - - abstract remove(payload: RemovePayload): Promise; - - abstract count(payload: CountPayload): Promise; - - abstract flush(): Promise; - - /* - |-------------------------------------------------------------------------------- - | Destructor - |-------------------------------------------------------------------------------- - */ - - destroy() { - this.#channel.close(); - } -} - -/* - |-------------------------------------------------------------------------------- - | Utilities - |-------------------------------------------------------------------------------- - */ - -export function addOptions( - cursor: Cursor, - options: QueryOptions, -): 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 = - | { - type: "insertOne" | "updateOne"; - data: AnyObject; - } - | { - type: "insertMany" | "updateMany" | "remove"; - data: AnyObject[]; - }; - -export type QueryOptions = { - 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; -}; - -export type InsertOnePayload = Prettify< - CollectionPayload & - PrimaryKeyPayload & { - values: AnyObject; - } ->; - -export type InsertManyPayload = Prettify< - CollectionPayload & - PrimaryKeyPayload & { - values: AnyObject[]; - } ->; - -export type FindByIdPayload = Prettify< - CollectionPayload & { - id: string; - } ->; - -export type FindPayload = Prettify< - CollectionPayload & { - condition?: Criteria; - options?: QueryOptions; - } ->; - -export type UpdatePayload = Prettify< - CollectionPayload & - PrimaryKeyPayload & { - condition: Criteria; - modifier: Modifier; - arrayFilters?: AnyObject[]; - } ->; - -export type ReplacePayload = Prettify< - CollectionPayload & - PrimaryKeyPayload & { - condition: Criteria; - document: AnyObject; - } ->; - -export type RemovePayload = Prettify< - CollectionPayload & - PrimaryKeyPayload & { - condition: Criteria; - } ->; - -export type CountPayload = Prettify< - CollectionPayload & { - condition?: Criteria; - } ->; - -type CollectionPayload = { - collection: string; -}; - -type PrimaryKeyPayload = { - pkey: string; -}; diff --git a/src/types.ts b/src/types.ts index 2c449f0..2324813 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,171 +1,11 @@ -import type { BSONRegExp, BSONType } from "bson"; +/** + * Represents an unknown document with global support. + */ +export type AnyDocument = { + [key: string]: any; +}; +/** + * Simplifies a complex type. + */ export type Prettify = { [K in keyof T]: T[K] } & {}; - -export type AnyDocument = Record; - -export type Filter = { - [P in keyof TSchema]?: Condition; -} & RootFilterOperators & - Record; - -export type UpdateFilter = { - $inc?: OnlyFieldsOfType; - $set?: MatchKeysAndValues | MatchKeysToFunctionValues | Record; - $unset?: OnlyFieldsOfType; - $pull?: PullOperator; - $push?: PushOperator; -}; - -type RootFilterOperators = { - $and?: Filter[]; - $nor?: Filter[]; - $or?: Filter[]; - $text?: { - $search: string; - $language?: string; - $caseSensitive?: boolean; - $diacriticSensitive?: boolean; - }; - $where?: string | ((this: TSchema) => boolean); - $comment?: string | Document; -}; - -type Condition = AlternativeType | FilterOperators>; - -type AlternativeType = T extends ReadonlyArray ? T | RegExpOrString : RegExpOrString; - -type RegExpOrString = T extends string ? BSONRegExp | RegExp | T : T; - -type FilterOperators = { - $eq?: TValue; - $gt?: TValue; - $gte?: TValue; - $in?: ReadonlyArray; - $lt?: TValue; - $lte?: TValue; - $ne?: TValue; - $nin?: ReadonlyArray; - $not?: TValue extends string ? FilterOperators | RegExp : FilterOperators; - /** - * 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; - $jsonSchema?: Record; - $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; - $elemMatch?: Document; - $size?: TValue extends ReadonlyArray ? number : never; - $bitsAllClear?: BitwiseFilter; - $bitsAllSet?: BitwiseFilter; - $bitsAnyClear?: BitwiseFilter; - $bitsAnySet?: BitwiseFilter; - $rand?: Record; -}; - -type BSONTypeAlias = keyof typeof BSONType; - -type BitwiseFilter = number | ReadonlyArray; - -type OnlyFieldsOfType = IsAny< - TSchema[keyof TSchema], - Record, - AcceptedFields & - NotAcceptedFields & - Record ->; - -type MatchKeysAndValues = Readonly>; - -type MatchKeysToFunctionValues = { - readonly [key in keyof TSchema]?: (this: TSchema, value: TSchema[key]) => TSchema[key]; -}; - -type PullOperator = ({ - readonly [key in KeysOfAType>]?: - | Partial> - | FilterOperations>; -} & NotAcceptedFields>) & { - readonly [key: string]: FilterOperators | any; -}; - -type PushOperator = ({ - readonly [key in KeysOfAType>]?: - | Flatten - | ArrayOperator>>; -} & NotAcceptedFields>) & { - readonly [key: string]: ArrayOperator | any; -}; - -type KeysOfAType = { - [key in keyof TSchema]: NonNullable extends Type ? key : never; -}[keyof TSchema]; - -type AcceptedFields = { - readonly [key in KeysOfAType]?: AssignableType; -}; - -type NotAcceptedFields = { - readonly [key in KeysOfOtherType]?: never; -}; - -type Flatten = Type extends ReadonlyArray ? Item : Type; - -type IsAny = true extends false & Type ? ResultIfAny : ResultIfNotAny; - -type FilterOperations = - T extends Record - ? { - [key in keyof T]?: FilterOperators; - } - : FilterOperators; - -type ArrayOperator = { - $each?: Array>; - $slice?: number; - $position?: number; - $sort?: Sort; -}; - -type Sort = - | string - | Exclude< - SortDirection, - { - $meta: string; - } - > - | string[] - | { - [key: string]: SortDirection; - } - | Map - | [string, SortDirection][] - | [string, SortDirection]; - -type SortDirection = - | 1 - | -1 - | "asc" - | "desc" - | "ascending" - | "descending" - | { - $meta: string; - }; - -type KeysOfOtherType = { - [key in keyof TSchema]: NonNullable extends Type ? never : key; -}[keyof TSchema]; diff --git a/tests/users.mock.ts b/tests/users.mock.ts index 1baafce..22843cb 100644 --- a/tests/users.mock.ts +++ b/tests/users.mock.ts @@ -1,7 +1,4 @@ -import { clone } from "../src/clone.ts"; -import type { WithId } from "../src/types.ts"; - -const users: WithId[] = [ +const users: UserDocument[] = [ { id: "user-1", name: "John Doe", @@ -28,11 +25,12 @@ const users: WithId[] = [ }, ]; -export function getUsers(): WithId[] { - return clone(users); +export function getUsers(): UserDocument[] { + return JSON.parse(JSON.stringify(users)); } export type UserDocument = { + id: string; name: string; email: string; friends: Friend[];