refactor: simplify and add memory indexing
This commit is contained in:
@@ -24,7 +24,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"test": {
|
"test": {
|
||||||
"command": "deno test --allow-all",
|
"command": "deno test --allow-all ./src",
|
||||||
"description": "Run all tests using Deno’s built-in test runner."
|
"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",
|
"version": "5",
|
||||||
"specifiers": {
|
"specifiers": {
|
||||||
"npm:@biomejs/biome@*": "2.3.10",
|
|
||||||
"npm:@biomejs/biome@2.3.10": "2.3.10",
|
"npm:@biomejs/biome@2.3.10": "2.3.10",
|
||||||
"npm:@jsr/std__assert@1": "1.0.16",
|
"npm:@jsr/std__assert@1": "1.0.16",
|
||||||
"npm:@jsr/std__async@1": "1.0.16",
|
"npm:@jsr/std__async@1": "1.0.16",
|
||||||
"npm:@jsr/std__testing@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:@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:expect@30.2.0": "30.2.0",
|
||||||
"npm:fake-indexeddb@6.2.5": "6.2.5",
|
"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:idb@8.0.3": "8.0.3",
|
||||||
"npm:mingo@7.1.1": "7.1.1",
|
"npm:mingo@7.1.1": "7.1.1",
|
||||||
"npm:rfdc@1.4.1": "1.4.1",
|
"npm:sorted-btree@2.1.0": "2.1.0",
|
||||||
"npm:rxjs@7.8.2": "7.8.2",
|
|
||||||
"npm:zod@4.3.4": "4.3.4"
|
"npm:zod@4.3.4": "4.3.4"
|
||||||
},
|
},
|
||||||
"npm": {
|
"npm": {
|
||||||
@@ -174,6 +170,13 @@
|
|||||||
],
|
],
|
||||||
"tarball": "https://npm.jsr.io/~/11/@jsr/std__testing/1.0.16.tgz"
|
"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": {
|
"@jsr/valkyr__testcontainers@2.0.2": {
|
||||||
"integrity": "sha512-YnmfraYFr3msoUGrIFeElm03nbQqXOaPu0QUT6JI3w6/mIYpVfzPxghkB7gn2RIc81QgrqjwKJE/AL3dltlR1w==",
|
"integrity": "sha512-YnmfraYFr3msoUGrIFeElm03nbQqXOaPu0QUT6JI3w6/mIYpVfzPxghkB7gn2RIc81QgrqjwKJE/AL3dltlR1w==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -254,9 +257,6 @@
|
|||||||
"bson@6.10.4": {
|
"bson@6.10.4": {
|
||||||
"integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng=="
|
"integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng=="
|
||||||
},
|
},
|
||||||
"bson@7.0.0": {
|
|
||||||
"integrity": "sha512-Kwc6Wh4lQ5OmkqqKhYGKIuELXl+EPYSCObVE6bWsp1T/cGkOCBN0I8wF/T44BiuhHyNi1mmKVPXk60d41xZ7kw=="
|
|
||||||
},
|
|
||||||
"chalk@4.1.2": {
|
"chalk@4.1.2": {
|
||||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -276,15 +276,12 @@
|
|||||||
"color-name@1.1.4": {
|
"color-name@1.1.4": {
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||||
},
|
},
|
||||||
"dot-prop@10.1.0": {
|
|
||||||
"integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==",
|
|
||||||
"dependencies": [
|
|
||||||
"type-fest"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"escape-string-regexp@2.0.0": {
|
"escape-string-regexp@2.0.0": {
|
||||||
"integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="
|
"integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="
|
||||||
},
|
},
|
||||||
|
"eventemitter3@5.0.1": {
|
||||||
|
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
|
||||||
|
},
|
||||||
"expect@30.2.0": {
|
"expect@30.2.0": {
|
||||||
"integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==",
|
"integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -299,9 +296,6 @@
|
|||||||
"fake-indexeddb@6.2.5": {
|
"fake-indexeddb@6.2.5": {
|
||||||
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="
|
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="
|
||||||
},
|
},
|
||||||
"fast-equals@6.0.0": {
|
|
||||||
"integrity": "sha512-PFhhIGgdM79r5Uztdj9Zb6Tt1zKafqVfdMGwVca1z5z6fbX7DmsySSuJd8HiP6I1j505DCS83cLxo5rmSNeVEA=="
|
|
||||||
},
|
|
||||||
"fill-range@7.1.1": {
|
"fill-range@7.1.1": {
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -401,7 +395,7 @@
|
|||||||
"integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==",
|
"integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@mongodb-js/saslprep",
|
"@mongodb-js/saslprep",
|
||||||
"bson@6.10.4",
|
"bson",
|
||||||
"mongodb-connection-string-url"
|
"mongodb-connection-string-url"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -431,18 +425,12 @@
|
|||||||
"react-is@18.3.1": {
|
"react-is@18.3.1": {
|
||||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
|
"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": {
|
"slash@3.0.0": {
|
||||||
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="
|
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="
|
||||||
},
|
},
|
||||||
|
"sorted-btree@2.1.0": {
|
||||||
|
"integrity": "sha512-AtYXy3lL+5jrATpbymC2bM8anN/3maLkmVCd94MzypnKjokfCid/zeS3rvXedv7W6ffSfqKIGdz3UaJPWRBZ0g=="
|
||||||
|
},
|
||||||
"sparse-bitfield@3.0.3": {
|
"sparse-bitfield@3.0.3": {
|
||||||
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
|
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -461,9 +449,6 @@
|
|||||||
"has-flag"
|
"has-flag"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"tagged-tag@1.0.0": {
|
|
||||||
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="
|
|
||||||
},
|
|
||||||
"to-regex-range@5.0.1": {
|
"to-regex-range@5.0.1": {
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -476,15 +461,6 @@
|
|||||||
"punycode"
|
"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": {
|
"undici-types@7.10.0": {
|
||||||
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="
|
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="
|
||||||
},
|
},
|
||||||
@@ -509,16 +485,13 @@
|
|||||||
"npm:@jsr/std__assert@1",
|
"npm:@jsr/std__assert@1",
|
||||||
"npm:@jsr/std__async@1",
|
"npm:@jsr/std__async@1",
|
||||||
"npm:@jsr/std__testing@1",
|
"npm:@jsr/std__testing@1",
|
||||||
|
"npm:@jsr/valkyr__event-emitter@1.0.1",
|
||||||
"npm:@jsr/valkyr__testcontainers@2",
|
"npm:@jsr/valkyr__testcontainers@2",
|
||||||
"npm:bson@7.0.0",
|
|
||||||
"npm:dot-prop@10.1.0",
|
|
||||||
"npm:expect@30.2.0",
|
"npm:expect@30.2.0",
|
||||||
"npm:fake-indexeddb@6.2.5",
|
"npm:fake-indexeddb@6.2.5",
|
||||||
"npm:fast-equals@6.0.0",
|
|
||||||
"npm:idb@8.0.3",
|
"npm:idb@8.0.3",
|
||||||
"npm:mingo@7.1.1",
|
"npm:mingo@7.1.1",
|
||||||
"npm:rfdc@1.4.1",
|
"npm:sorted-btree@2.1.0",
|
||||||
"npm:rxjs@7.8.2",
|
|
||||||
"npm:zod@4.3.4"
|
"npm:zod@4.3.4"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bson": "7.0.0",
|
"@valkyr/event-emitter": "npm:@jsr/valkyr__event-emitter@1.0.1",
|
||||||
"dot-prop": "10.1.0",
|
|
||||||
"fast-equals": "6.0.0",
|
|
||||||
"idb": "8.0.3",
|
"idb": "8.0.3",
|
||||||
"mingo": "7.1.1",
|
"mingo": "7.1.1",
|
||||||
"rfdc": "1.4.1",
|
|
||||||
"rxjs": "7.8.2",
|
|
||||||
"zod": "4.3.4"
|
"zod": "4.3.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AnyObject } from "mingo/types";
|
import type { AnyDocument } from "./types.ts";
|
||||||
|
|
||||||
export const BroadcastChannel =
|
export const BroadcastChannel =
|
||||||
globalThis.BroadcastChannel ??
|
globalThis.BroadcastChannel ??
|
||||||
@@ -11,13 +11,8 @@ export const BroadcastChannel =
|
|||||||
export type StorageBroadcast =
|
export type StorageBroadcast =
|
||||||
| {
|
| {
|
||||||
name: string;
|
name: string;
|
||||||
type: "insertOne" | "updateOne";
|
type: "insert" | "update" | "remove";
|
||||||
data: AnyObject;
|
data: AnyDocument[];
|
||||||
}
|
|
||||||
| {
|
|
||||||
name: string;
|
|
||||||
type: "insertMany" | "updateMany" | "remove";
|
|
||||||
data: AnyObject[];
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
name: string;
|
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 { AnyObject, Criteria } from "mingo/types";
|
||||||
import type { Modifier } from "mingo/updater";
|
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 type { ZodObject, ZodRawShape } from "zod";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
import { observe, observeOne } from "./observe/mod.ts";
|
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";
|
import type { AnyDocument } from "./types.ts";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -16,23 +17,54 @@ import type { AnyDocument } from "./types.ts";
|
|||||||
|
|
||||||
export class Collection<
|
export class Collection<
|
||||||
TOptions extends AnyCollectionOptions = AnyCollectionOptions,
|
TOptions extends AnyCollectionOptions = AnyCollectionOptions,
|
||||||
TAdapter extends Storage = TOptions["adapter"],
|
TStorage extends Storage = TOptions["storage"],
|
||||||
TPrimaryKey extends string = TOptions["primaryKey"],
|
|
||||||
TSchema extends AnyDocument = z.output<ZodObject<TOptions["schema"]>>,
|
TSchema extends AnyDocument = z.output<ZodObject<TOptions["schema"]>>,
|
||||||
> {
|
> {
|
||||||
declare readonly $schema: TSchema;
|
declare readonly $schema: TSchema;
|
||||||
|
|
||||||
constructor(readonly options: TOptions) {}
|
readonly #schema: ZodObject<TOptions["schema"]>;
|
||||||
|
readonly #pkey: string | number;
|
||||||
|
|
||||||
get observable(): {
|
constructor(readonly options: TOptions) {
|
||||||
change: Subject<ChangeEvent>;
|
this.#schema = z.strictObject(options.schema);
|
||||||
flush: Subject<void>;
|
this.#pkey = this.primaryKey;
|
||||||
} {
|
|
||||||
return this.storage.observable;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get storage(): TAdapter {
|
get name(): string {
|
||||||
return this.options.adapter;
|
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> {
|
async insert(documents: TSchema[]): Promise<void> {
|
||||||
return this.storage.resolve().then((storage) =>
|
return this.storage.resolve().then((storage) => storage.insert(documents));
|
||||||
storage.insertOne({
|
|
||||||
collection: this.options.name,
|
|
||||||
pkey: this.options.primaryKey,
|
|
||||||
values,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async insertMany(values: (TSchema | Omit<TSchema, TPrimaryKey>)[]): Promise<InsertResult> {
|
async update(
|
||||||
return this.storage.resolve().then((storage) =>
|
|
||||||
storage.insertMany({
|
|
||||||
collection: this.options.name,
|
|
||||||
pkey: this.options.primaryKey,
|
|
||||||
values,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateOne(
|
|
||||||
condition: Criteria<TSchema>,
|
condition: Criteria<TSchema>,
|
||||||
modifier: Modifier<TSchema>,
|
modifier: Modifier<TSchema>,
|
||||||
arrayFilters?: AnyObject[],
|
arrayFilters?: AnyObject[],
|
||||||
): Promise<UpdateResult> {
|
): Promise<UpdateResult> {
|
||||||
return this.storage.resolve().then((storage) =>
|
return this.storage.resolve().then((storage) => storage.update(condition, modifier, arrayFilters));
|
||||||
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,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(condition: Criteria<TSchema>): Promise<number> {
|
async remove(condition: Criteria<TSchema>): Promise<number> {
|
||||||
return this.storage
|
return this.storage.resolve().then((storage) => storage.remove(condition));
|
||||||
.resolve()
|
|
||||||
.then((storage) => storage.remove({ collection: this.options.name, pkey: this.options.primaryKey, condition }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -128,28 +107,9 @@ export class Collection<
|
|||||||
): Subscription;
|
): Subscription;
|
||||||
subscribe(condition: Criteria<TSchema> = {}, options?: QueryOptions, next?: (...args: any[]) => void): Subscription {
|
subscribe(condition: Criteria<TSchema> = {}, options?: QueryOptions, next?: (...args: any[]) => void): Subscription {
|
||||||
if (options?.limit === 1) {
|
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({
|
return observe(this as any, condition, options, (values, changed, type) => next?.(values, changed, type));
|
||||||
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));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -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
|
* Performs a mingo filter search over the collection data and returns
|
||||||
* a single document if one was found matching the filter and options.
|
* a single document if one was found matching the filter and options.
|
||||||
*/
|
*/
|
||||||
async findOne(condition: Criteria<TSchema> = {}, options?: QueryOptions): Promise<TSchema | undefined> {
|
async findOne(condition: Criteria<TSchema> = {}, options: QueryOptions = {}): Promise<TSchema | undefined> {
|
||||||
return this.find(condition, options).then(([document]) => document);
|
return this.findMany(condition, { ...options, limit: 1 }).then(([document]) => document);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Performs a mingo filter search over the collection data and returns any
|
* Performs a mingo filter search over the collection data and returns any
|
||||||
* documents matching the provided filter and options.
|
* 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
|
return this.storage
|
||||||
.resolve()
|
.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();
|
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"];
|
range?: QueryOptions["range"];
|
||||||
offset?: QueryOptions["offset"];
|
offset?: QueryOptions["offset"];
|
||||||
limit?: QueryOptions["limit"];
|
limit?: QueryOptions["limit"];
|
||||||
index?: QueryOptions["index"];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SubscribeToSingle = QueryOptions & {
|
export type SubscribeToSingle = QueryOptions & {
|
||||||
@@ -225,16 +195,26 @@ export type SubscribeToMany = QueryOptions & {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AnyCollectionOptions = CollectionOptions<any, any, any, any>;
|
type AnyCollectionOptions = CollectionOptions<any, any, any>;
|
||||||
|
|
||||||
type CollectionOptions<
|
type CollectionOptions<TName extends string, TStorage extends Storage, TSchema extends ZodRawShape> = {
|
||||||
TName extends string,
|
/**
|
||||||
TAdapter extends Storage,
|
* Name of the collection.
|
||||||
TPrimaryKey extends string | number | symbol,
|
*/
|
||||||
TSchema extends ZodRawShape,
|
|
||||||
> = {
|
|
||||||
name: TName;
|
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;
|
schema: TSchema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of custom indexes for the collection.
|
||||||
|
*/
|
||||||
|
indexes: Index[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { hashCodeQuery } from "../../hash.ts";
|
import type { Criteria } from "mingo/types";
|
||||||
import type { QueryOptions } from "../../storage/mod.ts";
|
|
||||||
import type { Document, Filter } from "../../types.ts";
|
|
||||||
|
|
||||||
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 #cache = new Map<number, string[]>();
|
||||||
readonly #documents = new Map<string, TSchema>();
|
readonly #documents = new Map<string, TSchema>();
|
||||||
|
|
||||||
hash(filter: Filter<TSchema>, options: QueryOptions): number {
|
hash(condition: Criteria<TSchema>, options: QueryOptions = {}): number {
|
||||||
return hashCodeQuery(filter, options);
|
return hashCodeQuery(condition, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
set(hashCode: number, documents: TSchema[]) {
|
set(hashCode: number, documents: TSchema[]) {
|
||||||
@@ -23,7 +25,7 @@ export class IndexedDBCache<TSchema extends Document = Document> {
|
|||||||
get(hashCode: number): TSchema[] | undefined {
|
get(hashCode: number): TSchema[] | undefined {
|
||||||
const ids = this.#cache.get(hashCode);
|
const ids = this.#cache.get(hashCode);
|
||||||
if (ids !== undefined) {
|
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 { Collection } from "../../collection.ts";
|
||||||
import type { DBLogger } from "../../logger.ts";
|
import type { DBLogger } from "../../logger.ts";
|
||||||
import type { Document } from "../../types.ts";
|
import type { Index, Registrars } from "../../registrars.ts";
|
||||||
import type { Registrars } from "../registrars.ts";
|
|
||||||
import { IndexedDBStorage } from "./storage.ts";
|
import { IndexedDBStorage } from "./storage.ts";
|
||||||
|
|
||||||
export class IndexedDB<TCollections extends StringRecord<Document>> {
|
export class IndexedDB<TOptions extends IndexedDBOptions> {
|
||||||
readonly #collections = new Map<keyof TCollections, Collection<TCollections[keyof TCollections]>>();
|
readonly #collections = new Map<string, Collection>();
|
||||||
readonly #db: Promise<IDBPDatabase<unknown>>;
|
readonly #db: Promise<IDBPDatabase<unknown>>;
|
||||||
|
|
||||||
constructor(readonly options: Options) {
|
constructor(readonly options: TOptions) {
|
||||||
this.#db = openDB(options.name, options.version ?? 1, {
|
this.#db = openDB(options.name, options.version ?? 1, {
|
||||||
upgrade: (db: IDBPDatabase) => {
|
upgrade: (db: IDBPDatabase) => {
|
||||||
for (const { name, primaryKey = "id", indexes = [] } of options.registrars) {
|
for (const { name, indexes = [] } of options.registrars) {
|
||||||
const store = db.createObjectStore(name as string, { keyPath: primaryKey });
|
const store = db.createObjectStore(name);
|
||||||
store.createIndex(primaryKey, primaryKey, { unique: true });
|
|
||||||
for (const [keyPath, options] of indexes) {
|
for (const [keyPath, options] of indexes) {
|
||||||
store.createIndex(keyPath, keyPath, options);
|
store.createIndex(keyPath, keyPath, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
for (const { name, primaryKey = "id" } of options.registrars) {
|
for (const { name, schema, indexes } of options.registrars) {
|
||||||
this.#collections.set(
|
this.#collections.set(
|
||||||
name,
|
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);
|
const collection = this.#collections.get(name);
|
||||||
if (collection === undefined) {
|
if (collection === undefined) {
|
||||||
throw new Error(`Collection '${name as string}' not found`);
|
throw new Error(`Collection '${name as string}' not found`);
|
||||||
@@ -65,13 +78,9 @@ export class IndexedDB<TCollections extends StringRecord<Document>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function log() {}
|
type IndexedDBOptions<TRegistrars extends Array<Registrars> = Array<any>> = {
|
||||||
|
|
||||||
type StringRecord<TCollections> = { [x: string]: TCollections };
|
|
||||||
|
|
||||||
type Options = {
|
|
||||||
name: string;
|
name: string;
|
||||||
|
registrars: TRegistrars;
|
||||||
version?: number;
|
version?: number;
|
||||||
registrars: Registrars[];
|
|
||||||
log?: DBLogger;
|
log?: DBLogger;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,30 +1,18 @@
|
|||||||
import type { IDBPDatabase } from "idb";
|
import type { IDBPDatabase } from "idb";
|
||||||
import { Query, update } from "mingo";
|
import { Query, update } from "mingo";
|
||||||
import type { Criteria, Options } from "mingo/types";
|
import type { Criteria } from "mingo/types";
|
||||||
import type { CloneMode, Modifier } from "mingo/updater";
|
import type { Modifier } from "mingo/updater";
|
||||||
|
|
||||||
import { type DBLogger, InsertLog, QueryLog, RemoveLog, ReplaceLog, UpdateLog } from "../../logger.ts";
|
import { type DBLogger, InsertLog, QueryLog, RemoveLog, UpdateLog } from "../../logger.ts";
|
||||||
import { getDocumentWithPrimaryKey } from "../../primary-key.ts";
|
import type { Index } from "../../registrars.ts";
|
||||||
import { DuplicateDocumentError } from "../../storage/errors.ts";
|
import { addOptions, type QueryOptions, Storage, type UpdateResult } from "../../storage.ts";
|
||||||
import {
|
import type { AnyDocument } from "../../types.ts";
|
||||||
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 { IndexedDBCache } from "./cache.ts";
|
import { IndexedDBCache } from "./cache.ts";
|
||||||
|
|
||||||
const OBJECT_PROTOTYPE = Object.getPrototypeOf({});
|
const OBJECT_PROTOTYPE = Object.getPrototypeOf({});
|
||||||
const OBJECT_TAG = "[object Object]";
|
const OBJECT_TAG = "[object Object]";
|
||||||
|
|
||||||
export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Document = Document> extends Storage<
|
export class IndexedDBStorage<TSchema extends AnyDocument = AnyDocument> extends Storage<TSchema> {
|
||||||
TPrimaryKey,
|
|
||||||
TSchema
|
|
||||||
> {
|
|
||||||
readonly #cache = new IndexedDBCache<TSchema>();
|
readonly #cache = new IndexedDBCache<TSchema>();
|
||||||
|
|
||||||
readonly #promise: Promise<IDBPDatabase>;
|
readonly #promise: Promise<IDBPDatabase>;
|
||||||
@@ -33,11 +21,11 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
name: string,
|
name: string,
|
||||||
primaryKey: TPrimaryKey,
|
indexes: Index[],
|
||||||
promise: Promise<IDBPDatabase>,
|
promise: Promise<IDBPDatabase>,
|
||||||
readonly log: DBLogger,
|
readonly log: DBLogger = function log() {},
|
||||||
) {
|
) {
|
||||||
super(name, primaryKey);
|
super(name, indexes);
|
||||||
this.#promise = promise;
|
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 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" });
|
const tx = this.db.transaction(this.name, "readwrite", { durability: "relaxed" });
|
||||||
await Promise.all(
|
await Promise.all(documents.map((document) => tx.store.add(document)));
|
||||||
values.map((values) => {
|
|
||||||
const document = getDocumentWithPrimaryKey(this.primaryKey, values);
|
|
||||||
documents.push(document);
|
|
||||||
return tx.store.add(document);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await tx.done;
|
await tx.done;
|
||||||
|
|
||||||
this.broadcast("insertMany", documents);
|
this.broadcast("insert", documents);
|
||||||
this.#cache.flush();
|
this.#cache.flush();
|
||||||
|
|
||||||
this.log(logger.result());
|
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> {
|
async getByIndex(index: string, value: string): Promise<TSchema[]> {
|
||||||
return this.db.getFromIndex(this.name, "id", id);
|
return this.db.getAllFromIndex(this.name, index, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async find(filter: Filter<TSchema>, options: QueryOptions = {}): Promise<TSchema[]> {
|
async find(condition: Criteria<TSchema> = {}, options?: QueryOptions): Promise<TSchema[]> {
|
||||||
const logger = new QueryLog(this.name, { filter, options });
|
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);
|
const cached = this.#cache.get(hashCode);
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
this.log(logger.result({ cached: true }));
|
this.log(logger.result({ cached: true }));
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const indexes = this.#resolveIndexes(filter);
|
const indexes = this.#resolveIndexes(condition);
|
||||||
let cursor = new Query(filter).find<TSchema>(await this.#getAll({ ...options, ...indexes }));
|
let cursor = new Query(condition).find<TSchema>(await this.#getAll({ ...options, ...indexes }));
|
||||||
if (options !== undefined) {
|
if (options !== undefined) {
|
||||||
cursor = addOptions(cursor, options);
|
cursor = addOptions(cursor, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
const documents = cursor.all() as TSchema[];
|
const documents = cursor.all();
|
||||||
this.#cache.set(this.#cache.hash(filter, options), documents);
|
this.#cache.set(this.#cache.hash(condition, options), documents);
|
||||||
|
|
||||||
this.log(logger.result());
|
this.log(logger.result());
|
||||||
|
|
||||||
@@ -172,10 +132,7 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async #getAll({ index, offset, range, limit }: QueryOptions) {
|
async #getAll({ offset, range, limit }: QueryOptions) {
|
||||||
if (index !== undefined) {
|
|
||||||
return this.#getAllByIndex(index);
|
|
||||||
}
|
|
||||||
if (range !== undefined) {
|
if (range !== undefined) {
|
||||||
return this.db.getAll(this.name, IDBKeyRange.bound(range.from, range.to));
|
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);
|
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) {
|
async #getAllByOffset(value: string, direction: 1 | -1, limit?: number) {
|
||||||
if (direction === 1) {
|
if (direction === 1) {
|
||||||
return this.db.getAll(this.name, IDBKeyRange.lowerBound(value), limit);
|
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(
|
async update(
|
||||||
filter: Filter<TSchema>,
|
condition: Criteria<TSchema>,
|
||||||
modifier: Modifier<TSchema>,
|
modifier: Modifier<TSchema>,
|
||||||
arrayFilters?: Filter<TSchema>[],
|
arrayFilters?: TSchema[],
|
||||||
condition?: Criteria<TSchema>,
|
|
||||||
options: { cloneMode?: CloneMode; queryOptions?: Partial<Options> } = { cloneMode: "deep" },
|
|
||||||
): Promise<UpdateResult> {
|
): Promise<UpdateResult> {
|
||||||
if (typeof filter.id === "string") {
|
const logger = new UpdateLog(this.name, { condition, modifier, arrayFilters });
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateMany(
|
const ids = await this.find(condition).then((data) => data.map((d) => d.id));
|
||||||
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 documents: TSchema[] = [];
|
const documents: TSchema[] = [];
|
||||||
let modifiedCount = 0;
|
let modifiedCount = 0;
|
||||||
@@ -271,7 +192,7 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
|
|||||||
if (current === undefined) {
|
if (current === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const modified = update(current, modifier, arrayFilters, condition, options);
|
const modified = update(current, modifier, arrayFilters, condition, { cloneMode: "deep" });
|
||||||
if (modified.length > 0) {
|
if (modified.length > 0) {
|
||||||
modifiedCount += 1;
|
modifiedCount += 1;
|
||||||
documents.push(current);
|
documents.push(current);
|
||||||
@@ -283,71 +204,12 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
|
|||||||
|
|
||||||
await tx.done;
|
await tx.done;
|
||||||
|
|
||||||
this.broadcast("updateMany", documents);
|
this.broadcast("update", documents);
|
||||||
this.#cache.flush();
|
this.#cache.flush();
|
||||||
|
|
||||||
this.log(logger.result());
|
this.log(logger.result());
|
||||||
|
|
||||||
return new UpdateResult(ids.length, modifiedCount);
|
return { matchedCount: 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -356,10 +218,10 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
|
|||||||
|--------------------------------------------------------------------------------
|
|--------------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
|
|
||||||
async remove(filter: Filter<TSchema>): Promise<RemoveResult> {
|
async remove(condition: Criteria<TSchema>): Promise<number> {
|
||||||
const logger = new RemoveLog(this.name, { filter });
|
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");
|
const tx = this.db.transaction(this.name, "readwrite");
|
||||||
|
|
||||||
await Promise.all(documents.map((data) => tx.store.delete(data.id)));
|
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 }));
|
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> {
|
async count(condition: Criteria<TSchema>): Promise<number> {
|
||||||
if (filter !== undefined) {
|
if (condition !== undefined) {
|
||||||
return (await this.find(filter)).length;
|
return (await this.find(condition)).length;
|
||||||
}
|
}
|
||||||
return this.db.count(this.name);
|
return this.db.count(this.name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,49 @@
|
|||||||
import { Collection } from "../../collection.ts";
|
import { Collection } from "../../collection.ts";
|
||||||
import type { Document } from "../../types.ts";
|
import type { Index, Registrars } from "../../registrars.ts";
|
||||||
import type { Registrars } from "../registrars.ts";
|
|
||||||
import { MemoryStorage } from "./storage.ts";
|
import { MemoryStorage } from "./storage.ts";
|
||||||
|
|
||||||
type Options = {
|
export class MemoryDatabase<TOptions extends MemoryDatabaseOptions> {
|
||||||
name: string;
|
readonly #collections = new Map<string, Collection>();
|
||||||
registrars: Registrars[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export class MemoryDatabase<T extends Record<string, Document>> {
|
constructor(readonly options: TOptions) {
|
||||||
readonly name: string;
|
for (const { name, schema, indexes } of options.registrars) {
|
||||||
readonly #collections = new Map<keyof T, Collection<T[keyof T]>>();
|
this.#collections.set(
|
||||||
|
name,
|
||||||
constructor(readonly options: Options) {
|
new Collection({
|
||||||
this.name = options.name;
|
name,
|
||||||
for (const { name } of options.registrars) {
|
storage: new MemoryStorage(name, indexes),
|
||||||
this.#collections.set(name, new Collection(name, new MemoryStorage(name)));
|
schema,
|
||||||
|
indexes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return this.options.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get registrars() {
|
||||||
|
return this.options.registrars;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------------
|
|--------------------------------------------------------------------------------
|
||||||
| Fetchers
|
| 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);
|
const collection = this.#collections.get(name);
|
||||||
if (collection === undefined) {
|
if (collection === undefined) {
|
||||||
throw new Error(`Collection '${name as string}' not found`);
|
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 { 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 { IndexManager, type IndexSpec } from "../../index/manager.ts";
|
||||||
import { Collections } from "../../storage/collections.ts";
|
import type { UpdateResult } from "../../storage.ts";
|
||||||
import type { UpdatePayload } from "../../storage/mod.ts";
|
import { addOptions, type QueryOptions, Storage } from "../../storage.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 type { AnyDocument } from "../../types.ts";
|
import type { AnyDocument } from "../../types.ts";
|
||||||
|
|
||||||
export class MemoryStorage extends Storage {
|
export class MemoryStorage<TSchema extends AnyDocument = AnyDocument> extends Storage<TSchema> {
|
||||||
readonly #collections = new Collections();
|
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() {
|
async resolve() {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
async insertOne({ pkey, values, ...payload }: InsertOnePayload): Promise<InsertResult> {
|
async insert(documents: TSchema[]): Promise<void> {
|
||||||
const collection = this.#collections.get(payload.collection);
|
for (const document of documents) {
|
||||||
|
this.index.insert(document);
|
||||||
const document = getDocumentWithPrimaryKey(pkey, values);
|
|
||||||
if (collection.has(document[pkey])) {
|
|
||||||
return { insertCount: 0, insertIds: [] };
|
|
||||||
}
|
}
|
||||||
|
this.broadcast("insert", documents);
|
||||||
collection.set(document[pkey], document);
|
|
||||||
this.broadcast("insertOne", document);
|
|
||||||
|
|
||||||
return { insertCount: 1, insertIds: [document[pkey]] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async insertMany({ pkey, values, ...payload }: InsertManyPayload): Promise<InsertResult> {
|
async getByIndex(index: string, value: string): Promise<TSchema[]> {
|
||||||
const collection = this.#collections.get(payload.collection);
|
return this.index.get(index)?.get(value) ?? [];
|
||||||
|
|
||||||
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> {
|
async find(condition: Criteria<TSchema> = {}, options?: QueryOptions): Promise<TSchema[]> {
|
||||||
return this.#collections.get(collection).get(id);
|
let cursor = new Query(condition).find<TSchema>(this.documents);
|
||||||
}
|
|
||||||
|
|
||||||
async find({ condition = {}, options, ...payload }: FindPayload): Promise<AnyDocument[]> {
|
|
||||||
let cursor = new Query(condition).find<AnyDocument>(this.#collections.documents(payload.collection));
|
|
||||||
if (options !== undefined) {
|
if (options !== undefined) {
|
||||||
cursor = addOptions(cursor, options);
|
cursor = addOptions(cursor, options);
|
||||||
}
|
}
|
||||||
return cursor.all();
|
return cursor.all();
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateOne({ pkey, condition, modifier, arrayFilters, ...payload }: UpdatePayload): Promise<UpdateResult> {
|
async update(
|
||||||
const collection = this.#collections.get(payload.collection);
|
condition: Criteria<TSchema>,
|
||||||
|
modifier: Modifier<TSchema>,
|
||||||
|
arrayFilters?: TSchema[],
|
||||||
|
): Promise<UpdateResult> {
|
||||||
|
const documents: TSchema[] = [];
|
||||||
|
|
||||||
let matchedCount = 0;
|
let matchedCount = 0;
|
||||||
let modifiedCount = 0;
|
let modifiedCount = 0;
|
||||||
|
|
||||||
for (const document of await this.find({ collection: payload.collection, condition, options: { limit: 1 } })) {
|
for (const document of await this.find(condition)) {
|
||||||
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 })) {
|
|
||||||
matchedCount += 1;
|
matchedCount += 1;
|
||||||
const modified = update(document, modifier, arrayFilters, undefined, { cloneMode: "deep" });
|
const modified = update(document, modifier, arrayFilters, undefined, { cloneMode: "deep" });
|
||||||
if (modified.length > 0) {
|
if (modified.length > 0) {
|
||||||
modifiedCount += 1;
|
modifiedCount += 1;
|
||||||
documents.push(document);
|
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 };
|
return { matchedCount, modifiedCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove({ pkey, condition, ...payload }: RemovePayload): Promise<number> {
|
async remove(condition: Criteria<TSchema>): Promise<number> {
|
||||||
const collection = this.#collections.get(payload.collection);
|
const documents = await this.find(condition);
|
||||||
|
|
||||||
const documents = await this.find({ collection: payload.collection, condition });
|
|
||||||
for (const document of documents) {
|
for (const document of documents) {
|
||||||
collection.delete(document[pkey]);
|
this.documents.delete(document);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.broadcast("remove", documents);
|
this.broadcast("remove", documents);
|
||||||
|
|
||||||
return documents.length;
|
return documents.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
async count({ collection, condition = {} }: CountPayload): Promise<number> {
|
async count(condition: Criteria<TSchema>): Promise<number> {
|
||||||
return new Query(condition).find(this.#collections.documents(collection)).all().length;
|
return new Query(condition).find(this.documents).all().length;
|
||||||
}
|
}
|
||||||
|
|
||||||
async flush(): Promise<void> {
|
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;
|
let hash = 0;
|
||||||
if (value.length === 0) {
|
if (value.length === 0) {
|
||||||
return hash;
|
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;
|
readonly type = "update" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ReplaceLog extends LogEvent implements DBLogEvent {
|
|
||||||
readonly type = "replace" as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RemoveLog extends LogEvent implements DBLogEvent {
|
export class RemoveLog extends LogEvent implements DBLogEvent {
|
||||||
readonly type = "remove" as const;
|
readonly type = "remove" as const;
|
||||||
}
|
}
|
||||||
@@ -67,4 +63,4 @@ export type DBLogEvent = {
|
|||||||
message?: string;
|
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 "./collection.ts";
|
||||||
export * from "./databases/mod.ts";
|
export * from "./databases/indexeddb/database.ts";
|
||||||
export * from "./storage/mod.ts";
|
export * from "./databases/memory/database.ts";
|
||||||
export type { Document, Filter } from "./types.ts";
|
export * from "./storage.ts";
|
||||||
|
export type { AnyDocument } from "./types.ts";
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Query } from "mingo";
|
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>(
|
export function isMatch<TSchema extends AnyDocument = AnyDocument>(
|
||||||
document: WithId<TSchema>,
|
document: TSchema,
|
||||||
filter?: Filter<WithId<TSchema>>,
|
condition?: Criteria<TSchema>,
|
||||||
): boolean {
|
): 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 { Collection } from "../collection.ts";
|
||||||
import type { Document, Filter, WithId } from "../types.ts";
|
import type { AnyDocument } from "../types.ts";
|
||||||
import { isMatch } from "./is-match.ts";
|
import { isMatch } from "./is-match.ts";
|
||||||
|
|
||||||
export function observeOne<TSchema extends Document = Document>(
|
export function observeOne<TCollection extends Collection>(
|
||||||
collection: Collection<TSchema>,
|
collection: TCollection,
|
||||||
filter: Filter<WithId<TSchema>>,
|
condition: Criteria<AnyDocument>,
|
||||||
onChange: (document: Document | undefined) => void,
|
onChange: (document: AnyDocument | undefined) => void,
|
||||||
): {
|
): Subscription {
|
||||||
unsubscribe: () => void;
|
collection.findOne(condition).then((document) => onChange(document));
|
||||||
} {
|
return collection.onChange(({ type, data }) => {
|
||||||
collection.findOne(filter).then(onChange);
|
|
||||||
|
|
||||||
const subscription = collection.observable.change.subscribe(({ type, data }) => {
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "insertOne":
|
case "insert":
|
||||||
case "updateOne": {
|
case "update": {
|
||||||
if (isMatch<TSchema>(data, filter) === true) {
|
for (const document of data) {
|
||||||
onChange(data);
|
if (isMatch(document, condition) === true) {
|
||||||
|
onChange(document);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "remove": {
|
case "remove": {
|
||||||
for (const document of data) {
|
for (const document of data) {
|
||||||
if (isMatch<TSchema>(document, filter) === true) {
|
if (isMatch(document, condition) === true) {
|
||||||
onChange(undefined);
|
onChange(undefined);
|
||||||
break;
|
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 { Query } from "mingo";
|
||||||
import type { AnyObject, Criteria } from "mingo/types";
|
import type { Criteria } from "mingo/types";
|
||||||
|
|
||||||
import type { Collection } from "../collection.ts";
|
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 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,
|
collection: TCollection,
|
||||||
condition: Criteria<TSchema>,
|
condition: Criteria<AnyDocument>,
|
||||||
options: QueryOptions | undefined,
|
options: QueryOptions | undefined,
|
||||||
onChange: (documents: TSchema[], changed: TSchema[], type: ChangeEvent["type"]) => void,
|
onChange: (documents: AnyDocument[], changed: AnyDocument[], type: ChangeEvent["type"]) => void,
|
||||||
): {
|
): Subscription {
|
||||||
unsubscribe: () => void;
|
const documents = new Map<string | number, AnyDocument>();
|
||||||
} {
|
|
||||||
const store = Store.create();
|
|
||||||
|
|
||||||
let debounce: any;
|
let debounce: any;
|
||||||
|
|
||||||
collection.find(condition, options).then(async (documents) => {
|
// ### Init
|
||||||
const resolved = await store.resolve(documents);
|
// Find the initial documents and send them to the change listener.
|
||||||
onChange(resolved, resolved, "insertMany");
|
|
||||||
|
collection.findMany(condition, options).then(async (documents) => {
|
||||||
|
onChange(documents, documents, "insert");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ### Subscriptions
|
||||||
|
|
||||||
const subscriptions = [
|
const subscriptions = [
|
||||||
collection.observable.flush.subscribe(() => {
|
collection.onFlush(() => {
|
||||||
clearTimeout(debounce);
|
clearTimeout(debounce);
|
||||||
store.flush();
|
|
||||||
onChange([], [], "remove");
|
onChange([], [], "remove");
|
||||||
}),
|
}),
|
||||||
collection.observable.change.subscribe(async ({ type, data }) => {
|
collection.onChange(async ({ type, data }) => {
|
||||||
let changed: AnyObject[] = [];
|
const changed: AnyDocument[] = [];
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "insertOne":
|
case "insert": {
|
||||||
case "updateOne": {
|
for (const document of data) {
|
||||||
changed = await store[type](data, condition);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "insertMany":
|
|
||||||
case "updateMany":
|
|
||||||
case "remove": {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changed.length > 0) {
|
if (changed.length > 0) {
|
||||||
clearTimeout(debounce);
|
clearTimeout(debounce);
|
||||||
debounce = setTimeout(() => {
|
debounce = setTimeout(() => {
|
||||||
store.getDocuments().then((documents) => {
|
onChange(applyQueryOptions(Array.from(documents.values()), options), changed, type);
|
||||||
onChange(applyQueryOptions(documents, options), changed, type);
|
|
||||||
});
|
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -60,14 +84,13 @@ export function observe<TCollection extends Collection, TSchema extends AnyObjec
|
|||||||
for (const subscription of subscriptions) {
|
for (const subscription of subscriptions) {
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
}
|
}
|
||||||
store.destroy();
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyQueryOptions(documents: AnyDocument[], options?: QueryOptions): AnyDocument[] {
|
function applyQueryOptions(documents: AnyDocument[], options?: QueryOptions): AnyDocument[] {
|
||||||
if (options !== undefined) {
|
if (options !== undefined) {
|
||||||
return addOptions(new Query({}).find<AnyDocument>(documents), options).all();
|
return addOptions<AnyDocument>(new Query({}).find<AnyDocument>(documents), options).all();
|
||||||
}
|
}
|
||||||
return documents;
|
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;
|
|
||||||
};
|
|
||||||
178
src/types.ts
178
src/types.ts
@@ -1,171 +1,11 @@
|
|||||||
import type { BSONRegExp, BSONType } from "bson";
|
/**
|
||||||
|
* Represents an unknown document with global support.
|
||||||
|
*/
|
||||||
|
export type AnyDocument = {
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplifies a complex type.
|
||||||
|
*/
|
||||||
export type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
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.
|
|
||||||
*/
|
|
||||||
$exists?: boolean;
|
|
||||||
$type?: BSONType | BSONTypeAlias;
|
|
||||||
$expr?: Record<string, any>;
|
|
||||||
$jsonSchema?: Record<string, any>;
|
|
||||||
$mod?: TValue extends number ? [number, number] : never;
|
|
||||||
$regex?: TValue extends string ? RegExp | string : never;
|
|
||||||
$options?: TValue extends string ? string : never;
|
|
||||||
$geoIntersects?: {
|
|
||||||
$geometry: Document;
|
|
||||||
};
|
|
||||||
$geoWithin?: Document;
|
|
||||||
$near?: Document;
|
|
||||||
$nearSphere?: Document;
|
|
||||||
$maxDistance?: number;
|
|
||||||
$all?: ReadonlyArray<any>;
|
|
||||||
$elemMatch?: Document;
|
|
||||||
$size?: TValue extends ReadonlyArray<any> ? number : never;
|
|
||||||
$bitsAllClear?: BitwiseFilter;
|
|
||||||
$bitsAllSet?: BitwiseFilter;
|
|
||||||
$bitsAnyClear?: BitwiseFilter;
|
|
||||||
$bitsAnySet?: BitwiseFilter;
|
|
||||||
$rand?: Record<string, never>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type BSONTypeAlias = keyof typeof BSONType;
|
|
||||||
|
|
||||||
type BitwiseFilter = number | ReadonlyArray<number>;
|
|
||||||
|
|
||||||
type OnlyFieldsOfType<TSchema, FieldType = any, AssignableType = FieldType> = IsAny<
|
|
||||||
TSchema[keyof TSchema],
|
|
||||||
Record<string, FieldType>,
|
|
||||||
AcceptedFields<TSchema, FieldType, AssignableType> &
|
|
||||||
NotAcceptedFields<TSchema, FieldType> &
|
|
||||||
Record<string, AssignableType>
|
|
||||||
>;
|
|
||||||
|
|
||||||
type MatchKeysAndValues<TSchema> = Readonly<Partial<TSchema>>;
|
|
||||||
|
|
||||||
type MatchKeysToFunctionValues<TSchema> = {
|
|
||||||
readonly [key in keyof TSchema]?: (this: TSchema, value: TSchema[key]) => TSchema[key];
|
|
||||||
};
|
|
||||||
|
|
||||||
type PullOperator<TSchema> = ({
|
|
||||||
readonly [key in KeysOfAType<TSchema, ReadonlyArray<any>>]?:
|
|
||||||
| Partial<Flatten<TSchema[key]>>
|
|
||||||
| FilterOperations<Flatten<TSchema[key]>>;
|
|
||||||
} & NotAcceptedFields<TSchema, ReadonlyArray<any>>) & {
|
|
||||||
readonly [key: string]: FilterOperators<any> | any;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PushOperator<TSchema> = ({
|
|
||||||
readonly [key in KeysOfAType<TSchema, ReadonlyArray<any>>]?:
|
|
||||||
| Flatten<TSchema[key]>
|
|
||||||
| ArrayOperator<Array<Flatten<TSchema[key]>>>;
|
|
||||||
} & NotAcceptedFields<TSchema, ReadonlyArray<any>>) & {
|
|
||||||
readonly [key: string]: ArrayOperator<any> | any;
|
|
||||||
};
|
|
||||||
|
|
||||||
type KeysOfAType<TSchema, Type> = {
|
|
||||||
[key in keyof TSchema]: NonNullable<TSchema[key]> extends Type ? key : never;
|
|
||||||
}[keyof TSchema];
|
|
||||||
|
|
||||||
type AcceptedFields<TSchema, FieldType, AssignableType> = {
|
|
||||||
readonly [key in KeysOfAType<TSchema, FieldType>]?: AssignableType;
|
|
||||||
};
|
|
||||||
|
|
||||||
type NotAcceptedFields<TSchema, FieldType> = {
|
|
||||||
readonly [key in KeysOfOtherType<TSchema, FieldType>]?: never;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Flatten<Type> = Type extends ReadonlyArray<infer Item> ? Item : Type;
|
|
||||||
|
|
||||||
type IsAny<Type, ResultIfAny, ResultIfNotAny> = true extends false & Type ? ResultIfAny : ResultIfNotAny;
|
|
||||||
|
|
||||||
type FilterOperations<T> =
|
|
||||||
T extends Record<string, any>
|
|
||||||
? {
|
|
||||||
[key in keyof T]?: FilterOperators<T[key]>;
|
|
||||||
}
|
|
||||||
: FilterOperators<T>;
|
|
||||||
|
|
||||||
type ArrayOperator<Type> = {
|
|
||||||
$each?: Array<Flatten<Type>>;
|
|
||||||
$slice?: number;
|
|
||||||
$position?: number;
|
|
||||||
$sort?: Sort;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Sort =
|
|
||||||
| string
|
|
||||||
| Exclude<
|
|
||||||
SortDirection,
|
|
||||||
{
|
|
||||||
$meta: string;
|
|
||||||
}
|
|
||||||
>
|
|
||||||
| string[]
|
|
||||||
| {
|
|
||||||
[key: string]: SortDirection;
|
|
||||||
}
|
|
||||||
| Map<string, SortDirection>
|
|
||||||
| [string, SortDirection][]
|
|
||||||
| [string, SortDirection];
|
|
||||||
|
|
||||||
type SortDirection =
|
|
||||||
| 1
|
|
||||||
| -1
|
|
||||||
| "asc"
|
|
||||||
| "desc"
|
|
||||||
| "ascending"
|
|
||||||
| "descending"
|
|
||||||
| {
|
|
||||||
$meta: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type KeysOfOtherType<TSchema, Type> = {
|
|
||||||
[key in keyof TSchema]: NonNullable<TSchema[key]> extends Type ? never : key;
|
|
||||||
}[keyof TSchema];
|
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import { clone } from "../src/clone.ts";
|
const users: UserDocument[] = [
|
||||||
import type { WithId } from "../src/types.ts";
|
|
||||||
|
|
||||||
const users: WithId<UserDocument>[] = [
|
|
||||||
{
|
{
|
||||||
id: "user-1",
|
id: "user-1",
|
||||||
name: "John Doe",
|
name: "John Doe",
|
||||||
@@ -28,11 +25,12 @@ const users: WithId<UserDocument>[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getUsers(): WithId<UserDocument>[] {
|
export function getUsers(): UserDocument[] {
|
||||||
return clone(users);
|
return JSON.parse(JSON.stringify(users));
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserDocument = {
|
export type UserDocument = {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
friends: Friend[];
|
friends: Friend[];
|
||||||
|
|||||||
Reference in New Issue
Block a user