refactor: simplify and add memory indexing
This commit is contained in:
@@ -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."
|
||||
},
|
||||
|
||||
|
||||
63
deno.lock
generated
63
deno.lock
generated
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import makeClone from "rfdc";
|
||||
|
||||
export const clone = makeClone();
|
||||
@@ -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<ZodObject<TOptions["schema"]>>,
|
||||
> {
|
||||
declare readonly $schema: TSchema;
|
||||
|
||||
constructor(readonly options: TOptions) {}
|
||||
readonly #schema: ZodObject<TOptions["schema"]>;
|
||||
readonly #pkey: string | number;
|
||||
|
||||
get observable(): {
|
||||
change: Subject<ChangeEvent>;
|
||||
flush: Subject<void>;
|
||||
} {
|
||||
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<TSchema, TPrimaryKey>): Promise<InsertResult> {
|
||||
return this.storage.resolve().then((storage) =>
|
||||
storage.insertOne({
|
||||
collection: this.options.name,
|
||||
pkey: this.options.primaryKey,
|
||||
values,
|
||||
}),
|
||||
);
|
||||
async insert(documents: TSchema[]): Promise<void> {
|
||||
return this.storage.resolve().then((storage) => storage.insert(documents));
|
||||
}
|
||||
|
||||
async insertMany(values: (TSchema | Omit<TSchema, TPrimaryKey>)[]): Promise<InsertResult> {
|
||||
return this.storage.resolve().then((storage) =>
|
||||
storage.insertMany({
|
||||
collection: this.options.name,
|
||||
pkey: this.options.primaryKey,
|
||||
values,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async updateOne(
|
||||
async update(
|
||||
condition: Criteria<TSchema>,
|
||||
modifier: Modifier<TSchema>,
|
||||
arrayFilters?: AnyObject[],
|
||||
): Promise<UpdateResult> {
|
||||
return this.storage.resolve().then((storage) =>
|
||||
storage.updateOne({
|
||||
collection: this.options.name,
|
||||
pkey: this.options.primaryKey,
|
||||
condition,
|
||||
modifier,
|
||||
arrayFilters,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async updateMany(
|
||||
condition: Criteria<TSchema>,
|
||||
modifier: Modifier<TSchema>,
|
||||
arrayFilters?: AnyObject[],
|
||||
): Promise<UpdateResult> {
|
||||
return this.storage.resolve().then((storage) =>
|
||||
storage.updateMany({
|
||||
collection: this.options.name,
|
||||
pkey: this.options.primaryKey,
|
||||
condition,
|
||||
modifier,
|
||||
arrayFilters,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async replaceOne(condition: Criteria<TSchema>, document: TSchema): Promise<UpdateResult> {
|
||||
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<TSchema>): Promise<number> {
|
||||
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<TSchema> = {}, 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<TSchema> = {},
|
||||
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<TSchema> = {}): Observable<TSchema | undefined> {
|
||||
return new Observable<TSchema | undefined>((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<TSchema | undefined> {
|
||||
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<TSchema> = {}, options?: QueryOptions): Promise<TSchema | undefined> {
|
||||
return this.find(condition, options).then(([document]) => document);
|
||||
async findOne(condition: Criteria<TSchema> = {}, options: QueryOptions = {}): Promise<TSchema | undefined> {
|
||||
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<TSchema> = {}, options?: QueryOptions): Promise<TSchema[]> {
|
||||
async findMany(condition: Criteria<TSchema> = {}, options?: QueryOptions): Promise<TSchema[]> {
|
||||
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<TSchema>) => 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<any, any, any, any>;
|
||||
type AnyCollectionOptions = CollectionOptions<any, any, any>;
|
||||
|
||||
type CollectionOptions<
|
||||
TName extends string,
|
||||
TAdapter extends Storage,
|
||||
TPrimaryKey extends string | number | symbol,
|
||||
TSchema extends ZodRawShape,
|
||||
> = {
|
||||
type CollectionOptions<TName extends string, TStorage extends Storage, TSchema extends ZodRawShape> = {
|
||||
/**
|
||||
* 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[];
|
||||
};
|
||||
|
||||
@@ -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<TSchema extends Document = Document> {
|
||||
import { hashCodeQuery } from "../../hash.ts";
|
||||
import type { QueryOptions } from "../../storage.ts";
|
||||
import type { AnyDocument } from "../../types.ts";
|
||||
|
||||
export class IndexedDBCache<TSchema extends AnyDocument = AnyDocument> {
|
||||
readonly #cache = new Map<number, string[]>();
|
||||
readonly #documents = new Map<string, TSchema>();
|
||||
|
||||
hash(filter: Filter<TSchema>, options: QueryOptions): number {
|
||||
return hashCodeQuery(filter, options);
|
||||
hash(condition: Criteria<TSchema>, options: QueryOptions = {}): number {
|
||||
return hashCodeQuery(condition, options);
|
||||
}
|
||||
|
||||
set(hashCode: number, documents: TSchema[]) {
|
||||
@@ -23,7 +25,7 @@ export class IndexedDBCache<TSchema extends Document = Document> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<TCollections extends StringRecord<Document>> {
|
||||
readonly #collections = new Map<keyof TCollections, Collection<TCollections[keyof TCollections]>>();
|
||||
export class IndexedDB<TOptions extends IndexedDBOptions> {
|
||||
readonly #collections = new Map<string, Collection>();
|
||||
readonly #db: Promise<IDBPDatabase<unknown>>;
|
||||
|
||||
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<TCollections extends StringRecord<Document>> {
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
collection<Name extends keyof TCollections = keyof TCollections>(name: Name) {
|
||||
collection<
|
||||
TName extends TOptions["registrars"][number]["name"],
|
||||
TSchema = Extract<TOptions["registrars"][number], { name: TName }>["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<TCollections extends StringRecord<Document>> {
|
||||
}
|
||||
}
|
||||
|
||||
function log() {}
|
||||
|
||||
type StringRecord<TCollections> = { [x: string]: TCollections };
|
||||
|
||||
type Options = {
|
||||
type IndexedDBOptions<TRegistrars extends Array<Registrars> = Array<any>> = {
|
||||
name: string;
|
||||
registrars: TRegistrars;
|
||||
version?: number;
|
||||
registrars: Registrars[];
|
||||
log?: DBLogger;
|
||||
};
|
||||
|
||||
@@ -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<TPrimaryKey extends string, TSchema extends Document = Document> extends Storage<
|
||||
TPrimaryKey,
|
||||
TSchema
|
||||
> {
|
||||
export class IndexedDBStorage<TSchema extends AnyDocument = AnyDocument> extends Storage<TSchema> {
|
||||
readonly #cache = new IndexedDBCache<TSchema>();
|
||||
|
||||
readonly #promise: Promise<IDBPDatabase>;
|
||||
@@ -33,11 +21,11 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
primaryKey: TPrimaryKey,
|
||||
indexes: Index[],
|
||||
promise: Promise<IDBPDatabase>,
|
||||
readonly log: DBLogger,
|
||||
readonly log: DBLogger = function log() {},
|
||||
) {
|
||||
super(name, primaryKey);
|
||||
super(name, indexes);
|
||||
this.#promise = promise;
|
||||
}
|
||||
|
||||
@@ -69,45 +57,17 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async insertOne(values: TSchema | Omit<TSchema, TPrimaryKey>): Promise<InsertOneResult> {
|
||||
async insert(documents: TSchema[]): Promise<void> {
|
||||
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<TSchema, TPrimaryKey>)[]): Promise<InsertManyResult> {
|
||||
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<TPrimaryKey extends string, TSchema extends Docume
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async findById(id: string): Promise<TSchema | undefined> {
|
||||
return this.db.getFromIndex(this.name, "id", id);
|
||||
async getByIndex(index: string, value: string): Promise<TSchema[]> {
|
||||
return this.db.getAllFromIndex(this.name, index, value);
|
||||
}
|
||||
|
||||
async find(filter: Filter<TSchema>, options: QueryOptions = {}): Promise<TSchema[]> {
|
||||
const logger = new QueryLog(this.name, { filter, options });
|
||||
async find(condition: Criteria<TSchema> = {}, options?: QueryOptions): Promise<TSchema[]> {
|
||||
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<TSchema>(await this.#getAll({ ...options, ...indexes }));
|
||||
const indexes = this.#resolveIndexes(condition);
|
||||
let cursor = new Query(condition).find<TSchema>(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<TPrimaryKey extends string, TSchema extends Docume
|
||||
return {};
|
||||
}
|
||||
|
||||
async #getAll({ index, offset, range, limit }: QueryOptions) {
|
||||
if (index !== undefined) {
|
||||
return this.#getAllByIndex(index);
|
||||
}
|
||||
async #getAll({ offset, range, limit }: QueryOptions) {
|
||||
if (range !== undefined) {
|
||||
return this.db.getAll(this.name, IDBKeyRange.bound(range.from, range.to));
|
||||
}
|
||||
@@ -185,23 +142,6 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
|
||||
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);
|
||||
@@ -233,33 +173,14 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async updateOne(
|
||||
filter: Filter<TSchema>,
|
||||
async update(
|
||||
condition: Criteria<TSchema>,
|
||||
modifier: Modifier<TSchema>,
|
||||
arrayFilters?: Filter<TSchema>[],
|
||||
condition?: Criteria<TSchema>,
|
||||
options: { cloneMode?: CloneMode; queryOptions?: Partial<Options> } = { cloneMode: "deep" },
|
||||
arrayFilters?: TSchema[],
|
||||
): Promise<UpdateResult> {
|
||||
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<TSchema>,
|
||||
modifier: Modifier<TSchema>,
|
||||
arrayFilters?: Filter<TSchema>[],
|
||||
condition?: Criteria<TSchema>,
|
||||
options: { cloneMode?: CloneMode; queryOptions?: Partial<Options> } = { cloneMode: "deep" },
|
||||
): Promise<UpdateResult> {
|
||||
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<TPrimaryKey extends string, TSchema extends Docume
|
||||
if (current === undefined) {
|
||||
return;
|
||||
}
|
||||
const modified = update(current, modifier, arrayFilters, condition, options);
|
||||
const modified = update(current, modifier, arrayFilters, condition, { cloneMode: "deep" });
|
||||
if (modified.length > 0) {
|
||||
modifiedCount += 1;
|
||||
documents.push(current);
|
||||
@@ -283,71 +204,12 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
|
||||
|
||||
await tx.done;
|
||||
|
||||
this.broadcast("updateMany", documents);
|
||||
this.broadcast("update", documents);
|
||||
this.#cache.flush();
|
||||
|
||||
this.log(logger.result());
|
||||
|
||||
return new UpdateResult(ids.length, modifiedCount);
|
||||
}
|
||||
|
||||
async replace(filter: Filter<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: 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<TSchema>,
|
||||
arrayFilters?: Filter<TSchema>[],
|
||||
condition?: Criteria<TSchema>,
|
||||
options: { cloneMode?: CloneMode; queryOptions?: Partial<Options> } = { cloneMode: "deep" },
|
||||
): Promise<UpdateResult> {
|
||||
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<TPrimaryKey extends string, TSchema extends Docume
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async remove(filter: Filter<TSchema>): Promise<RemoveResult> {
|
||||
const logger = new RemoveLog(this.name, { filter });
|
||||
async remove(condition: Criteria<TSchema>): Promise<number> {
|
||||
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<TPrimaryKey extends string, TSchema extends Docume
|
||||
|
||||
this.log(logger.result({ count: documents.length }));
|
||||
|
||||
return new RemoveResult(documents.length);
|
||||
return documents.length;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -379,9 +241,9 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async count(filter?: Filter<TSchema>): Promise<number> {
|
||||
if (filter !== undefined) {
|
||||
return (await this.find(filter)).length;
|
||||
async count(condition: Criteria<TSchema>): Promise<number> {
|
||||
if (condition !== undefined) {
|
||||
return (await this.find(condition)).length;
|
||||
}
|
||||
return this.db.count(this.name);
|
||||
}
|
||||
|
||||
@@ -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<TOptions extends MemoryDatabaseOptions> {
|
||||
readonly #collections = new Map<string, Collection>();
|
||||
|
||||
export class MemoryDatabase<T extends Record<string, Document>> {
|
||||
readonly name: string;
|
||||
readonly #collections = new Map<keyof T, Collection<T[keyof T]>>();
|
||||
|
||||
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 extends keyof T>(name: Name): Collection<T[Name]> {
|
||||
collection<
|
||||
TName extends TOptions["registrars"][number]["name"],
|
||||
TSchema = Extract<TOptions["registrars"][number], { name: TName }>["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<T extends Record<string, Document>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type MemoryDatabaseOptions<TRegistrars extends Array<Registrars> = Array<any>> = {
|
||||
name: string;
|
||||
registrars: TRegistrars;
|
||||
};
|
||||
|
||||
@@ -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<TSchema extends AnyDocument = AnyDocument> extends Storage<TSchema> {
|
||||
readonly index: IndexManager<TSchema>;
|
||||
|
||||
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<InsertResult> {
|
||||
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<void> {
|
||||
for (const document of documents) {
|
||||
this.index.insert(document);
|
||||
}
|
||||
this.broadcast("insert", documents);
|
||||
}
|
||||
|
||||
collection.set(document[pkey], document);
|
||||
this.broadcast("insertOne", document);
|
||||
|
||||
return { insertCount: 1, insertIds: [document[pkey]] };
|
||||
async getByIndex(index: string, value: string): Promise<TSchema[]> {
|
||||
return this.index.get(index)?.get(value) ?? [];
|
||||
}
|
||||
|
||||
async insertMany({ pkey, values, ...payload }: InsertManyPayload): Promise<InsertResult> {
|
||||
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 findById({ collection, id }: FindByIdPayload): Promise<AnyObject | undefined> {
|
||||
return this.#collections.get(collection).get(id);
|
||||
}
|
||||
|
||||
async find({ condition = {}, options, ...payload }: FindPayload): Promise<AnyDocument[]> {
|
||||
let cursor = new Query(condition).find<AnyDocument>(this.#collections.documents(payload.collection));
|
||||
async find(condition: Criteria<TSchema> = {}, options?: QueryOptions): Promise<TSchema[]> {
|
||||
let cursor = new Query(condition).find<TSchema>(this.documents);
|
||||
if (options !== undefined) {
|
||||
cursor = addOptions(cursor, options);
|
||||
}
|
||||
return cursor.all();
|
||||
}
|
||||
|
||||
async updateOne({ pkey, condition, modifier, arrayFilters, ...payload }: UpdatePayload): Promise<UpdateResult> {
|
||||
const collection = this.#collections.get(payload.collection);
|
||||
async update(
|
||||
condition: Criteria<TSchema>,
|
||||
modifier: Modifier<TSchema>,
|
||||
arrayFilters?: TSchema[],
|
||||
): Promise<UpdateResult> {
|
||||
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<UpdateResult> {
|
||||
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);
|
||||
if (modifiedCount > 0) {
|
||||
this.broadcast("update", documents);
|
||||
}
|
||||
|
||||
return { matchedCount, modifiedCount };
|
||||
}
|
||||
|
||||
async replace({ pkey, condition, document, ...payload }: ReplacePayload): Promise<UpdateResult> {
|
||||
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);
|
||||
}
|
||||
|
||||
this.broadcast("updateMany", documents);
|
||||
|
||||
return { matchedCount, modifiedCount };
|
||||
}
|
||||
|
||||
async remove({ pkey, condition, ...payload }: RemovePayload): Promise<number> {
|
||||
const collection = this.#collections.get(payload.collection);
|
||||
|
||||
const documents = await this.find({ collection: payload.collection, condition });
|
||||
async remove(condition: Criteria<TSchema>): Promise<number> {
|
||||
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<number> {
|
||||
return new Query(condition).find(this.#collections.documents(collection)).all().length;
|
||||
async count(condition: Criteria<TSchema>): Promise<number> {
|
||||
return new Query(condition).find(this.documents).all().length;
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
this.#collections.flush();
|
||||
this.documents.clear();
|
||||
}
|
||||
}
|
||||
|
||||
29
src/databases/memory/tests/storage.test.ts
Normal file
29
src/databases/memory/tests/storage.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./indexeddb/database.ts";
|
||||
export * from "./memory/database.ts";
|
||||
@@ -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<string, AnyDocument>();
|
||||
|
||||
async resolve() {
|
||||
return this;
|
||||
}
|
||||
|
||||
async has(id: string): Promise<boolean> {
|
||||
return this.#documents.has(id);
|
||||
}
|
||||
|
||||
async insertOne(values: AnyDocument): Promise<InsertResult> {
|
||||
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<InsertResult> {
|
||||
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<TSchema | undefined> {
|
||||
return this.#documents.get(id);
|
||||
}
|
||||
|
||||
async find(filter?: Filter<TSchema>, options?: QueryOptions): Promise<TSchema[]> {
|
||||
let cursor = new Query(filter ?? {}).find<TSchema>(Array.from(this.#documents.values()));
|
||||
if (options !== undefined) {
|
||||
cursor = addOptions(cursor, options);
|
||||
}
|
||||
return cursor.all();
|
||||
}
|
||||
|
||||
async updateOne(
|
||||
filter: Filter<TSchema>,
|
||||
modifier: Modifier<TSchema>,
|
||||
arrayFilters?: Filter<TSchema>[],
|
||||
condition?: Criteria<TSchema>,
|
||||
options: { cloneMode?: CloneMode; queryOptions?: Partial<Options> } = { cloneMode: "deep" },
|
||||
): Promise<UpdateResult> {
|
||||
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<TSchema>,
|
||||
modifier: Modifier<TSchema>,
|
||||
arrayFilters?: Filter<TSchema>[],
|
||||
condition?: Criteria<TSchema>,
|
||||
options: { cloneMode?: CloneMode; queryOptions?: Partial<Options> } = { cloneMode: "deep" },
|
||||
): Promise<UpdateResult> {
|
||||
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<TSchema>, document: TSchema): Promise<UpdateResult> {
|
||||
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<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<TSchema>): Promise<number> {
|
||||
return new Query(filter ?? {}).find(Array.from(this.#documents.values())).all().length;
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
this.#documents.clear();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
10
src/hash.ts
10
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;
|
||||
|
||||
117
src/index/manager.ts
Normal file
117
src/index/manager.ts
Normal file
@@ -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<TSchema extends AnyDocument> {
|
||||
readonly primary: PrimaryIndex<TSchema>;
|
||||
|
||||
readonly unique = new Map<keyof TSchema, UniqueIndex>();
|
||||
readonly shared = new Map<keyof TSchema, SharedIndex>();
|
||||
|
||||
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>): TSchema[] | undefined {
|
||||
// const pks = new Set<any>();
|
||||
// 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";
|
||||
24
src/index/primary.ts
Normal file
24
src/index/primary.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { AnyDocument } from "../types.ts";
|
||||
|
||||
export type PrimaryKey = string;
|
||||
|
||||
export class PrimaryIndex<TSchema extends AnyDocument> {
|
||||
readonly #index = new Map<PrimaryKey, TSchema>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
46
src/index/shared.ts
Normal file
46
src/index/shared.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { PrimaryKey } from "./primary.ts";
|
||||
|
||||
export class SharedIndex {
|
||||
readonly #index = new Map<string, Set<PrimaryKey>>();
|
||||
|
||||
/**
|
||||
* 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<PrimaryKey> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/index/unique.ts
Normal file
20
src/index/unique.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { PrimaryKey } from "./primary.ts";
|
||||
|
||||
export class UniqueIndex {
|
||||
readonly #index = new Map<string, PrimaryKey>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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"];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<TSchema extends Document = Document>(
|
||||
document: WithId<TSchema>,
|
||||
filter?: Filter<WithId<TSchema>>,
|
||||
export function isMatch<TSchema extends AnyDocument = AnyDocument>(
|
||||
document: TSchema,
|
||||
condition?: Criteria<TSchema>,
|
||||
): boolean {
|
||||
return !filter || new Query(filter).test(document);
|
||||
return condition === undefined || new Query(condition).test(document);
|
||||
}
|
||||
|
||||
@@ -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<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 }) => {
|
||||
export function observeOne<TCollection extends Collection>(
|
||||
collection: TCollection,
|
||||
condition: Criteria<AnyDocument>,
|
||||
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<TSchema>(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<TSchema>(document, filter) === true) {
|
||||
if (isMatch(document, condition) === true) {
|
||||
onChange(undefined);
|
||||
break;
|
||||
}
|
||||
@@ -31,10 +34,4 @@ export function observeOne<TSchema extends Document = Document>(
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
subscription.unsubscribe();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<TCollection extends Collection, TSchema extends AnyObject = TCollection["$schema"]>(
|
||||
export function observe<TCollection extends Collection>(
|
||||
collection: TCollection,
|
||||
condition: Criteria<TSchema>,
|
||||
condition: Criteria<AnyDocument>,
|
||||
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<string | number, AnyDocument>();
|
||||
|
||||
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<TCollection extends Collection, TSchema extends AnyObjec
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
store.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyQueryOptions(documents: AnyDocument[], options?: QueryOptions): AnyDocument[] {
|
||||
if (options !== undefined) {
|
||||
return addOptions(new Query({}).find<AnyDocument>(documents), options).all();
|
||||
return addOptions<AnyDocument>(new Query({}).find<AnyDocument>(documents), options).all();
|
||||
}
|
||||
return documents;
|
||||
}
|
||||
|
||||
@@ -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<AnyDocument[]> {
|
||||
await this.storage.insertMany(documents);
|
||||
return this.getDocuments();
|
||||
}
|
||||
|
||||
async getDocuments(): Promise<AnyDocument[]> {
|
||||
return this.storage.find();
|
||||
}
|
||||
|
||||
async insertMany(documents: AnyDocument[], filter: Filter<AnyDocument>): Promise<AnyDocument[]> {
|
||||
const matched = [];
|
||||
for (const document of documents) {
|
||||
matched.push(...(await this.insertOne(document, filter)));
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
async insertOne(document: AnyDocument, filter: Filter<AnyDocument>): Promise<AnyDocument[]> {
|
||||
if (isMatch<AnyDocument>(document, filter)) {
|
||||
await this.storage.insertOne(document);
|
||||
return [document];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async updateMany(documents: AnyDocument[], filter: Filter<AnyDocument>): Promise<AnyDocument[]> {
|
||||
const matched = [];
|
||||
for (const document of documents) {
|
||||
matched.push(...(await this.updateOne(document, filter)));
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
async updateOne(document: AnyDocument, filter: Filter<AnyDocument>): Promise<AnyDocument[]> {
|
||||
if (await this.storage.has(document.id)) {
|
||||
await this.#updateOrRemove(document, filter);
|
||||
return [document];
|
||||
} else if (isMatch<AnyDocument>(document, filter)) {
|
||||
await this.storage.insertOne(document);
|
||||
return [document];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async remove(documents: AnyDocument[]): Promise<AnyDocument[]> {
|
||||
const matched = [];
|
||||
for (const document of documents) {
|
||||
if (isMatch<AnyDocument>(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<AnyDocument>): Promise<void> {
|
||||
if (isMatch<AnyDocument>(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();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { AnyDocument } from "./types.ts";
|
||||
|
||||
export function getDocumentWithPrimaryKey<TPKey extends string>(pkey: TPKey, document: AnyDocument): AnyDocument {
|
||||
if (Object.hasOwn(document, pkey) === true) {
|
||||
return document;
|
||||
}
|
||||
return { [pkey]: crypto.randomUUID(), ...document };
|
||||
}
|
||||
18
src/registrars.ts
Normal file
18
src/registrars.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ZodRawShape } from "zod";
|
||||
|
||||
export type Registrars<TSchema extends ZodRawShape = ZodRawShape> = {
|
||||
/**
|
||||
* 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[];
|
||||
};
|
||||
231
src/storage.ts
Normal file
231
src/storage.ts
Normal file
@@ -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<TSchema extends AnyDocument = AnyDocument> {
|
||||
readonly event = new EventEmitter<StorageEvent>();
|
||||
|
||||
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<StorageBroadcast>) => {
|
||||
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<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: "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<void>;
|
||||
|
||||
/**
|
||||
* 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<TSchema[]>;
|
||||
|
||||
/**
|
||||
* 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<TSchema>, options?: QueryOptions): Promise<TSchema[]>;
|
||||
|
||||
/**
|
||||
* 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<TSchema>,
|
||||
modifier: Modifier<TSchema>,
|
||||
arrayFilters?: TSchema[],
|
||||
): Promise<UpdateResult>;
|
||||
|
||||
/**
|
||||
* Remove documents matching the given condition.
|
||||
*
|
||||
* @param condition - Mingo criteria to filter documents to remove.
|
||||
*/
|
||||
abstract remove(condition: Criteria<TSchema>): Promise<number>;
|
||||
|
||||
/**
|
||||
* Get document count matching given condition.
|
||||
*
|
||||
* @param condition - Mingo criteria to count document against.
|
||||
*/
|
||||
abstract count(condition: Criteria<TSchema>): Promise<number>;
|
||||
|
||||
/**
|
||||
* Remove all documents in the storage.
|
||||
*/
|
||||
abstract flush(): Promise<void>;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Destructor
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
destroy() {
|
||||
this.#channel.close();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utilities
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export function addOptions<TSchema extends AnyDocument = AnyDocument>(
|
||||
cursor: Cursor<TSchema>,
|
||||
options: QueryOptions,
|
||||
): Cursor<TSchema> {
|
||||
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<TSchema extends AnyDocument = AnyDocument> = {
|
||||
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;
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { AnyObject } from "mingo/types";
|
||||
|
||||
import { CollectionNotFoundError } from "./errors.ts";
|
||||
|
||||
export class Collections {
|
||||
#collections = new Map<string, Documents>();
|
||||
|
||||
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<string, AnyObject>;
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -1,4 +0,0 @@
|
||||
export type InsertResult = {
|
||||
insertCount: number;
|
||||
insertIds: (string | number | symbol)[];
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export type UpdateResult = {
|
||||
matchedCount: number;
|
||||
modifiedCount: number;
|
||||
};
|
||||
@@ -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<ChangeEvent>;
|
||||
flush: Subject<void>;
|
||||
} = {
|
||||
change: new Subject<ChangeEvent>(),
|
||||
flush: new Subject<void>(),
|
||||
};
|
||||
|
||||
status: Status = "loading";
|
||||
|
||||
readonly #channel: BroadcastChannel;
|
||||
|
||||
constructor(readonly name: string) {
|
||||
this.#channel = new BroadcastChannel(`valkyr:db:${name}`);
|
||||
this.#channel.onmessage = ({ data }: MessageEvent<StorageBroadcast>) => {
|
||||
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["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<InsertResult>;
|
||||
|
||||
abstract insertMany(payload: InsertManyPayload): Promise<InsertResult>;
|
||||
|
||||
abstract findById(payload: FindByIdPayload): Promise<AnyObject | undefined>;
|
||||
|
||||
abstract find(payload: FindPayload): Promise<AnyObject[]>;
|
||||
|
||||
abstract updateOne(payload: UpdatePayload): Promise<UpdateResult>;
|
||||
|
||||
abstract updateMany(payload: UpdatePayload): Promise<UpdateResult>;
|
||||
|
||||
abstract replace(payload: ReplacePayload): Promise<UpdateResult>;
|
||||
|
||||
abstract remove(payload: RemovePayload): Promise<number>;
|
||||
|
||||
abstract count(payload: CountPayload): Promise<number>;
|
||||
|
||||
abstract flush(): Promise<void>;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Destructor
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
destroy() {
|
||||
this.#channel.close();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utilities
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export function addOptions<TSchema extends AnyObject = AnyObject>(
|
||||
cursor: Cursor<TSchema>,
|
||||
options: QueryOptions,
|
||||
): Cursor<TSchema> {
|
||||
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<AnyObject>;
|
||||
options?: QueryOptions;
|
||||
}
|
||||
>;
|
||||
|
||||
export type UpdatePayload = Prettify<
|
||||
CollectionPayload &
|
||||
PrimaryKeyPayload & {
|
||||
condition: Criteria<AnyObject>;
|
||||
modifier: Modifier<AnyObject>;
|
||||
arrayFilters?: AnyObject[];
|
||||
}
|
||||
>;
|
||||
|
||||
export type ReplacePayload = Prettify<
|
||||
CollectionPayload &
|
||||
PrimaryKeyPayload & {
|
||||
condition: Criteria<AnyObject>;
|
||||
document: AnyObject;
|
||||
}
|
||||
>;
|
||||
|
||||
export type RemovePayload = Prettify<
|
||||
CollectionPayload &
|
||||
PrimaryKeyPayload & {
|
||||
condition: Criteria<AnyObject>;
|
||||
}
|
||||
>;
|
||||
|
||||
export type CountPayload = Prettify<
|
||||
CollectionPayload & {
|
||||
condition?: Criteria<AnyObject>;
|
||||
}
|
||||
>;
|
||||
|
||||
type CollectionPayload = {
|
||||
collection: string;
|
||||
};
|
||||
|
||||
type PrimaryKeyPayload = {
|
||||
pkey: string;
|
||||
};
|
||||
174
src/types.ts
174
src/types.ts
@@ -1,171 +1,11 @@
|
||||
import type { BSONRegExp, BSONType } from "bson";
|
||||
|
||||
export type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
||||
|
||||
export type AnyDocument = Record<string, any>;
|
||||
|
||||
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.
|
||||
* Represents an unknown document with global support.
|
||||
*/
|
||||
$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>;
|
||||
export type AnyDocument = {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
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];
|
||||
/**
|
||||
* Simplifies a complex type.
|
||||
*/
|
||||
export type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { clone } from "../src/clone.ts";
|
||||
import type { WithId } from "../src/types.ts";
|
||||
|
||||
const users: WithId<UserDocument>[] = [
|
||||
const users: UserDocument[] = [
|
||||
{
|
||||
id: "user-1",
|
||||
name: "John Doe",
|
||||
@@ -28,11 +25,12 @@ const users: WithId<UserDocument>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function getUsers(): WithId<UserDocument>[] {
|
||||
return clone(users);
|
||||
export function getUsers(): UserDocument[] {
|
||||
return JSON.parse(JSON.stringify(users));
|
||||
}
|
||||
|
||||
export type UserDocument = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
friends: Friend[];
|
||||
|
||||
Reference in New Issue
Block a user