feat(release): 1.0.0
This commit is contained in:
24
.codeclimate.yml
Normal file
24
.codeclimate.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
version: "2"
|
||||
|
||||
plugins:
|
||||
eslint:
|
||||
enabled: true
|
||||
config: configs/eslint/index.js
|
||||
|
||||
checks:
|
||||
similar-code:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 75
|
||||
return-statements:
|
||||
config:
|
||||
threshold: 8
|
||||
|
||||
exclude_patterns:
|
||||
- "dist/"
|
||||
- "scripts/"
|
||||
- "**/node_modules/"
|
||||
- "**/tests/"
|
||||
- "**/*.test.ts"
|
||||
- "**/*.test.tsx"
|
||||
- "**/*.d.ts"
|
||||
67
.eslintrc.cjs
Normal file
67
.eslintrc.cjs
Normal file
@@ -0,0 +1,67 @@
|
||||
module.exports = {
|
||||
plugins: ["simple-import-sort", "check-file"],
|
||||
extends: ["prettier", "plugin:anti-trojan-source/recommended"],
|
||||
ignorePatterns: ["dist"],
|
||||
rules: {
|
||||
// ### Import
|
||||
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ["*/src"],
|
||||
message: "Do not directly import packages from the src/ directory."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
// ### Simple Import Sort
|
||||
|
||||
"simple-import-sort/imports": "error",
|
||||
"simple-import-sort/exports": "error",
|
||||
|
||||
// ### Check File
|
||||
|
||||
"check-file/filename-naming-convention": [
|
||||
"error",
|
||||
{
|
||||
"src/**/*.{tsx,ts}": "PASCAL_CASE"
|
||||
},
|
||||
{
|
||||
ignoreMiddleExtensions: true
|
||||
}
|
||||
]
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["tests/**/*.ts"],
|
||||
rules: {
|
||||
"no-restricted-imports": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["*.ts", "*.tsx"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
extends: ["plugin:@typescript-eslint/recommended"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-use-before-define": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
"@typescript-eslint/no-var-requires": "off",
|
||||
"@typescript-eslint/prefer-arrow-callback": "off",
|
||||
"@typescript-eslint/ban-types": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["types.d.ts", "main.ts", "mod.ts", "index.ts"],
|
||||
rules: {
|
||||
"check-file/filename-naming-convention": ["off"]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
58
.github/workflows/ci.yml
vendored
Normal file
58
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
Build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c
|
||||
|
||||
- uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd
|
||||
with:
|
||||
version: 7
|
||||
|
||||
- uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: pnpm
|
||||
|
||||
- run: npm install
|
||||
|
||||
Lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c
|
||||
|
||||
- uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd
|
||||
with:
|
||||
version: 7
|
||||
|
||||
- uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: pnpm
|
||||
|
||||
- run: npm install
|
||||
- run: npm run lint
|
||||
|
||||
Test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c
|
||||
|
||||
- uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd
|
||||
with:
|
||||
version: 7
|
||||
|
||||
- uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install
|
||||
- run: pnpm test
|
||||
59
.gitignore
vendored
Normal file
59
.gitignore
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
# IDE - VSCode
|
||||
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# IDE - IntelliJ
|
||||
|
||||
.idea/*
|
||||
!.idea/inspectionProfiles/
|
||||
!.idea/dictionaries/
|
||||
!.idea/codeStyles/
|
||||
!.idea/modules.xml
|
||||
!.idea/*.iml
|
||||
!.idea/README.md
|
||||
|
||||
# Development
|
||||
|
||||
.publish
|
||||
.turbo
|
||||
.docker
|
||||
.parcel-cache
|
||||
|
||||
# node_modules
|
||||
|
||||
node_modules
|
||||
|
||||
# cargo
|
||||
|
||||
/target
|
||||
Cargo.lock
|
||||
|
||||
# dist & pack
|
||||
|
||||
.contented
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# Tests
|
||||
|
||||
coverage
|
||||
|
||||
# OS
|
||||
|
||||
.DS_Store
|
||||
|
||||
# TypeScript
|
||||
|
||||
tsconfig.*.tsbuildinfo
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
lerna-debug.log*
|
||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
.vscode/
|
||||
node_modules/
|
||||
dist/
|
||||
8
.vscode/extensions.json
vendored
Normal file
8
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"csstools.postcss",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
53
.vscode/settings.json
vendored
Normal file
53
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"eslint.workingDirectories": [
|
||||
{
|
||||
"mode": "auto"
|
||||
}
|
||||
],
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"material-icon-theme.folders.associations": {
|
||||
"cerberus": "secure",
|
||||
"jsonrpc": "public"
|
||||
},
|
||||
"material-icon-theme.files.associations": {
|
||||
"index.ts": "tree",
|
||||
"main.ts": "tree",
|
||||
"mod.ts": "tree",
|
||||
"config.ts": "settings",
|
||||
"*.access.ts": "key",
|
||||
"*.database.ts": "database",
|
||||
"*.collection.ts": "mxml",
|
||||
"*.controller.ts": "makefile",
|
||||
"*.events.ts": "lib",
|
||||
"*.method.ts": "makefile",
|
||||
"*.methods.ts": "makefile",
|
||||
"*.mock.ts": "hardhat",
|
||||
"*.entity.ts": "database",
|
||||
"*.form.ts": "log",
|
||||
"*.gateway.ts": "parcel",
|
||||
"*.module.ts": "robot",
|
||||
"*.projector.ts": "apiblueprint",
|
||||
"*.role.ts": "playwright",
|
||||
"*.service.ts": "console",
|
||||
"*.validator.ts": "key",
|
||||
"*.component.tsx": "virtual",
|
||||
"*.modal.tsx": "opam",
|
||||
"*.view.tsx": "virtual",
|
||||
},
|
||||
"files.exclude": {
|
||||
"**/.turbo": true,
|
||||
"**/.git": true,
|
||||
"**/.svn": true,
|
||||
"**/.hg": true,
|
||||
"**/CVS": true,
|
||||
"**/.DS_Store": true,
|
||||
"**/Thumbs.db": true
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020-2023 Christoffer Rødvik, Valkyr
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
7
README.md
Normal file
7
README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Valkyr Database
|
||||
|
||||
`@valkyr/db` provides a mongo like persistent storage solution for browsers which comes with multiple storage adapter solutions. It's meant to provide an agnostic data layer which can be used for any popular web based framework such as `react`, `vue`, `svelte`, `angular`, and any other framework.
|
||||
|
||||
It's a write side storage solution written to compliment [mingo](https://github.com/kofrasa/mingo) used for the read side.
|
||||
|
||||
This solution is specifically written for client side storage, and is not optimized for performance. The main focus of `@valkyr/db` is to provide a simple but powerful data toolkit for quality of life.
|
||||
7397
package-lock.json
generated
Normal file
7397
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
87
package.json
Normal file
87
package.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"name": "@valkyr/db",
|
||||
"version": "1.0.0",
|
||||
"description": "Simple client side storage solution written in TypeScript.",
|
||||
"repository": "https://github.com/cmdo/valkyr.git",
|
||||
"bugs": "https://github.com/cmdo/valkyr/issues",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"keywords": [
|
||||
"browser",
|
||||
"database",
|
||||
"mingo",
|
||||
"mongodb"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -b ./tsconfig.build.json",
|
||||
"flush": "npm run clean && rm -rf ./node_modules",
|
||||
"clean": "rm -rf ./dist",
|
||||
"lint": "eslint ./src --fix",
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules npx jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"dot-prop": "8.0.2",
|
||||
"fast-equals": "5.0.1",
|
||||
"idb": "7.1.1",
|
||||
"mingo": "6.4.7",
|
||||
"nanoid": "5.0.2",
|
||||
"rfdc": "1.3.0",
|
||||
"rxjs": "7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.6",
|
||||
"@typescript-eslint/eslint-plugin": "^6.8.0",
|
||||
"@typescript-eslint/parser": "^6.8.0",
|
||||
"bson": "6.2.0",
|
||||
"eslint": "8.51.0",
|
||||
"eslint-config-prettier": "9.0.0",
|
||||
"eslint-plugin-anti-trojan-source": "1.1.1",
|
||||
"eslint-plugin-check-file": "2.6.2",
|
||||
"eslint-plugin-import": "2.28.1",
|
||||
"eslint-plugin-simple-import-sort": "10.0.0",
|
||||
"fake-indexeddb": "5.0.0",
|
||||
"jest": "29.7.0",
|
||||
"lint-staged": "15.0.2",
|
||||
"prettier": "3.0.3",
|
||||
"ts-jest": "29.1.1",
|
||||
"type-fest": "4.5.0",
|
||||
"typescript": "5.2.2"
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 120,
|
||||
"trailingComma": "none"
|
||||
},
|
||||
"jest": {
|
||||
"extensionsToTreatAsEsm": [
|
||||
".ts"
|
||||
],
|
||||
"verbose": true,
|
||||
"testEnvironment": "node",
|
||||
"modulePathIgnorePatterns": [
|
||||
"<rootDir>/node_modules",
|
||||
"<rootDir>/dist"
|
||||
],
|
||||
"moduleFileExtensions": [
|
||||
"ts",
|
||||
"js",
|
||||
"json"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1"
|
||||
},
|
||||
"testRegex": ".*\\.Test\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.ts$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"useESM": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/Broadcast.ts
Normal file
25
src/Broadcast.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Document, WithId } from "./Types.js";
|
||||
|
||||
export const BroadcastChannel =
|
||||
globalThis.BroadcastChannel ??
|
||||
class BroadcastChannelMock {
|
||||
onmessage?: any;
|
||||
postMessage() {}
|
||||
close() {}
|
||||
};
|
||||
|
||||
export type StorageBroadcast<TSchema extends Document = Document> =
|
||||
| {
|
||||
name: string;
|
||||
type: "insertOne" | "updateOne";
|
||||
data: WithId<TSchema>;
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
type: "insertMany" | "updateMany" | "remove";
|
||||
data: WithId<TSchema>[];
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
type: "flush";
|
||||
};
|
||||
3
src/Clone.ts
Normal file
3
src/Clone.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import makeClone from "rfdc";
|
||||
|
||||
export const clone = makeClone();
|
||||
172
src/Collection.ts
Normal file
172
src/Collection.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { Observable, Subscription } from "rxjs";
|
||||
|
||||
import { observe, observeOne } from "./Observe/mod.js";
|
||||
import {
|
||||
ChangeEvent,
|
||||
InsertManyResult,
|
||||
InsertOneResult,
|
||||
Options,
|
||||
RemoveResult,
|
||||
Storage,
|
||||
UpdateResult
|
||||
} from "./Storage/mod.js";
|
||||
import { Document, Filter, UpdateFilter, WithId } from "./Types.js";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Collection
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export class Collection<TSchema extends Document = Document> {
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly storage: Storage<TSchema>
|
||||
) {}
|
||||
|
||||
get observable() {
|
||||
return this.storage.observable;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Mutators
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async insertOne(document: Partial<WithId<TSchema>>): Promise<InsertOneResult> {
|
||||
return this.storage.resolve().then((storage) => storage.insertOne(document));
|
||||
}
|
||||
|
||||
async insertMany(documents: Partial<WithId<TSchema>>[]): Promise<InsertManyResult> {
|
||||
return this.storage.resolve().then((storage) => storage.insertMany(documents));
|
||||
}
|
||||
|
||||
async updateOne(filter: Filter<WithId<TSchema>>, update: UpdateFilter<TSchema>): Promise<UpdateResult> {
|
||||
return this.storage.resolve().then((storage) => storage.updateOne(filter, update));
|
||||
}
|
||||
|
||||
async updateMany(filter: Filter<WithId<TSchema>>, update: UpdateFilter<TSchema>): Promise<UpdateResult> {
|
||||
return this.storage.resolve().then((storage) => storage.updateMany(filter, update));
|
||||
}
|
||||
|
||||
async replaceOne(filter: Filter<WithId<TSchema>>, document: TSchema): Promise<UpdateResult> {
|
||||
return this.storage.resolve().then((storage) => storage.replace(filter, document));
|
||||
}
|
||||
|
||||
async remove(filter: Filter<WithId<TSchema>>): Promise<RemoveResult> {
|
||||
return this.storage.resolve().then((storage) => storage.remove(filter));
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Observers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
subscribe(
|
||||
filter?: Filter<WithId<TSchema>>,
|
||||
options?: SubscribeToSingle,
|
||||
next?: (document: WithId<TSchema> | undefined) => void
|
||||
): Subscription;
|
||||
subscribe(
|
||||
filter?: Filter<WithId<TSchema>>,
|
||||
options?: SubscribeToMany,
|
||||
next?: (documents: WithId<TSchema>[], changed: WithId<TSchema>[], type: ChangeEvent["type"]) => void
|
||||
): Subscription;
|
||||
subscribe(filter: Filter<WithId<TSchema>> = {}, options?: Options, next?: (...args: any[]) => void): Subscription {
|
||||
if (options?.limit === 1) {
|
||||
return this.#observeOne(filter).subscribe({ next });
|
||||
}
|
||||
return this.#observe(filter, options).subscribe({
|
||||
next: (value: [WithId<TSchema>[], WithId<TSchema>[], ChangeEvent["type"]]) => next?.(...value)
|
||||
});
|
||||
}
|
||||
|
||||
#observe(
|
||||
filter: Filter<WithId<TSchema>> = {},
|
||||
options?: Options
|
||||
): Observable<[WithId<TSchema>[], WithId<TSchema>[], ChangeEvent["type"]]> {
|
||||
return new Observable<[WithId<TSchema>[], WithId<TSchema>[], ChangeEvent["type"]]>((subscriber) => {
|
||||
return observe(this as any, filter, options, (values, changed, type) =>
|
||||
subscriber.next([values, changed, type] as any)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#observeOne(filter: Filter<WithId<TSchema>> = {}): Observable<WithId<TSchema> | undefined> {
|
||||
return new Observable<WithId<TSchema> | undefined>((subscriber) => {
|
||||
return observeOne(this as any, filter, (values) => subscriber.next(values as any));
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Queries
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Retrieve a record by the document 'id' key.
|
||||
*/
|
||||
async findById(id: string): Promise<WithId<TSchema> | undefined> {
|
||||
return this.storage.resolve().then((storage) => storage.findById(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a mingo filter search over the collection data and returns
|
||||
* a single document if one was found matching the filter and options.
|
||||
*/
|
||||
async findOne(filter: Filter<WithId<TSchema>> = {}, options?: Options): Promise<WithId<TSchema> | undefined> {
|
||||
return this.find(filter, options).then(([document]) => document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a mingo filter search over the collection data and returns any
|
||||
* documents matching the provided filter and options.
|
||||
*/
|
||||
async find(filter: Filter<WithId<TSchema>> = {}, options?: Options): Promise<WithId<TSchema>[]> {
|
||||
return this.storage.resolve().then((storage) => storage.find(filter, options));
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a mingo filter search over the collection data and returns
|
||||
* the count of all documents found matching the filter and options.
|
||||
*/
|
||||
async count(filter?: Filter<WithId<TSchema>>): Promise<number> {
|
||||
return this.storage.resolve().then((storage) => storage.count(filter));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all documents from the storage instance.
|
||||
*/
|
||||
flush(): void {
|
||||
this.storage.resolve().then((storage) => {
|
||||
storage.broadcast("flush");
|
||||
storage.flush();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type SubscriptionOptions = {
|
||||
sort?: Options["sort"];
|
||||
skip?: Options["skip"];
|
||||
range?: Options["range"];
|
||||
offset?: Options["offset"];
|
||||
limit?: Options["limit"];
|
||||
index?: Options["index"];
|
||||
};
|
||||
|
||||
export type SubscribeToSingle = Options & {
|
||||
limit: 1;
|
||||
};
|
||||
|
||||
export type SubscribeToMany = Options & {
|
||||
limit?: number;
|
||||
};
|
||||
34
src/Databases/IndexedDb.Cache.ts
Normal file
34
src/Databases/IndexedDb.Cache.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { hashCodeQuery } from "../Hash.js";
|
||||
import { Options } from "../Storage/mod.js";
|
||||
import type { Document, Filter, WithId } from "../Types.js";
|
||||
|
||||
export class IndexedDbCache<TSchema extends Document = Document> {
|
||||
readonly #cache = new Map<number, string[]>();
|
||||
readonly #documents = new Map<string, WithId<TSchema>>();
|
||||
|
||||
hash(filter: Filter<WithId<TSchema>>, options: Options): number {
|
||||
return hashCodeQuery(filter, options);
|
||||
}
|
||||
|
||||
set(hashCode: number, documents: WithId<TSchema>[]) {
|
||||
this.#cache.set(
|
||||
hashCode,
|
||||
documents.map((document) => document.id)
|
||||
);
|
||||
for (const document of documents) {
|
||||
this.#documents.set(document.id, document);
|
||||
}
|
||||
}
|
||||
|
||||
get(hashCode: number): WithId<TSchema>[] | undefined {
|
||||
const ids = this.#cache.get(hashCode);
|
||||
if (ids !== undefined) {
|
||||
return ids.map((id) => this.#documents.get(id) as WithId<TSchema>);
|
||||
}
|
||||
}
|
||||
|
||||
flush() {
|
||||
this.#cache.clear();
|
||||
this.#documents.clear();
|
||||
}
|
||||
}
|
||||
392
src/Databases/IndexedDb.Storage.ts
Normal file
392
src/Databases/IndexedDb.Storage.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import type { IDBPDatabase } from "idb";
|
||||
import { Query } from "mingo";
|
||||
import type { AnyVal } from "mingo/types";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { DBLogger, InsertLog, QueryLog, RemoveLog, ReplaceLog, UpdateLog } from "../Logger.js";
|
||||
import {
|
||||
addOptions,
|
||||
DuplicateDocumentError,
|
||||
getInsertManyResult,
|
||||
getInsertOneResult,
|
||||
Index,
|
||||
InsertManyResult,
|
||||
InsertOneResult,
|
||||
Options,
|
||||
RemoveResult,
|
||||
Storage,
|
||||
update,
|
||||
UpdateResult
|
||||
} from "../Storage/mod.js";
|
||||
import { Document, Filter, UpdateFilter, WithId } from "../Types.js";
|
||||
import { IndexedDbCache } from "./IndexedDb.Cache.js";
|
||||
|
||||
const OBJECT_PROTOTYPE = Object.getPrototypeOf({}) as AnyVal;
|
||||
const OBJECT_TAG = "[object Object]";
|
||||
|
||||
export class IndexedDbStorage<TSchema extends Document = Document> extends Storage<TSchema> {
|
||||
readonly #cache = new IndexedDbCache<TSchema>();
|
||||
readonly #promise: Promise<IDBPDatabase>;
|
||||
|
||||
#db?: IDBPDatabase;
|
||||
|
||||
constructor(name: string, promise: Promise<IDBPDatabase>, readonly log: DBLogger) {
|
||||
super(name);
|
||||
this.#promise = promise;
|
||||
}
|
||||
|
||||
async resolve() {
|
||||
if (this.#db === undefined) {
|
||||
this.#db = await this.#promise;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
async has(id: string): Promise<boolean> {
|
||||
const document = await this.db.getFromIndex(this.name, "id", id);
|
||||
if (document !== undefined) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get db() {
|
||||
if (this.#db === undefined) {
|
||||
throw new Error("Database not initialized");
|
||||
}
|
||||
return this.#db;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Insert
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async insertOne(data: Partial<WithId<TSchema>>): Promise<InsertOneResult> {
|
||||
const logger = new InsertLog(this.name);
|
||||
|
||||
const document = { ...data, id: data.id ?? nanoid() } as any;
|
||||
if (await this.has(document.id)) {
|
||||
throw new DuplicateDocumentError(document, this as any);
|
||||
}
|
||||
await this.db.transaction(this.name, "readwrite", { durability: "relaxed" }).store.add(document);
|
||||
|
||||
this.broadcast("insertOne", document);
|
||||
this.#cache.flush();
|
||||
|
||||
this.log(logger.result());
|
||||
|
||||
return getInsertOneResult(document);
|
||||
}
|
||||
|
||||
async insertMany(data: Partial<WithId<TSchema>>[]): Promise<InsertManyResult> {
|
||||
const logger = new InsertLog(this.name);
|
||||
|
||||
const documents: WithId<TSchema>[] = [];
|
||||
|
||||
const tx = this.db.transaction(this.name, "readwrite", { durability: "relaxed" });
|
||||
await Promise.all(
|
||||
data.map((data) => {
|
||||
const document = { ...data, id: data.id ?? nanoid() } as WithId<TSchema>;
|
||||
documents.push(document);
|
||||
return tx.store.add(document);
|
||||
})
|
||||
);
|
||||
await tx.done;
|
||||
|
||||
this.broadcast("insertMany", documents);
|
||||
this.#cache.flush();
|
||||
|
||||
this.log(logger.result());
|
||||
|
||||
return getInsertManyResult(documents);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Read
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async findById(id: string): Promise<WithId<TSchema> | undefined> {
|
||||
return this.db.getFromIndex(this.name, "id", id);
|
||||
}
|
||||
|
||||
async find(filter: Filter<WithId<TSchema>>, options: Options = {}): Promise<WithId<TSchema>[]> {
|
||||
const logger = new QueryLog(this.name, { filter, options });
|
||||
|
||||
const hashCode = this.#cache.hash(filter, options);
|
||||
const cached = this.#cache.get(hashCode);
|
||||
if (cached !== undefined) {
|
||||
this.log(logger.result({ cached: true }));
|
||||
return cached;
|
||||
}
|
||||
|
||||
const indexes = this.#resolveIndexes(filter);
|
||||
let cursor = new Query(filter).find(await this.#getAll({ ...options, ...indexes }));
|
||||
if (options !== undefined) {
|
||||
cursor = addOptions(cursor, options);
|
||||
}
|
||||
|
||||
const documents = cursor.all() as WithId<TSchema>[];
|
||||
this.#cache.set(this.#cache.hash(filter, options), documents);
|
||||
|
||||
this.log(logger.result());
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Prototype! Needs to cover more mongodb query cases and investigation around
|
||||
* nested indexing in indexeddb.
|
||||
*/
|
||||
#resolveIndexes(filter: any): { index?: { [key: string]: any } } {
|
||||
const indexNames = this.db.transaction(this.name, "readonly").store.indexNames;
|
||||
const index: { [key: string]: any } = {};
|
||||
for (const key in filter) {
|
||||
if (indexNames.contains(key) === true) {
|
||||
let val: any;
|
||||
if (isObject(filter[key]) === true) {
|
||||
if (filter[key]["$in"] !== undefined) {
|
||||
val = filter[key]["$in"];
|
||||
}
|
||||
} else {
|
||||
val = filter[key];
|
||||
}
|
||||
if (val !== undefined) {
|
||||
index[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(index).length > 0) {
|
||||
return { index };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async #getAll({ index, offset, range, limit }: Options) {
|
||||
if (index !== undefined) {
|
||||
return this.#getAllByIndex(index);
|
||||
}
|
||||
if (range !== undefined) {
|
||||
return this.db.getAll(this.name, IDBKeyRange.bound(range.from, range.to));
|
||||
}
|
||||
if (offset !== undefined) {
|
||||
return this.#getAllByOffset(offset.value, offset.direction, limit);
|
||||
}
|
||||
return this.db.getAll(this.name, undefined, limit);
|
||||
}
|
||||
|
||||
async #getAllByIndex(index: Index) {
|
||||
let result = new Set();
|
||||
for (const key in index) {
|
||||
const value = index[key];
|
||||
if (Array.isArray(value)) {
|
||||
for (const idx of value) {
|
||||
const values = await this.db.getAllFromIndex(this.name, key, idx);
|
||||
result = new Set([...result, ...values]);
|
||||
}
|
||||
} else {
|
||||
const values = await this.db.getAllFromIndex(this.name, key, value);
|
||||
result = new Set([...result, ...values]);
|
||||
}
|
||||
}
|
||||
return Array.from(result);
|
||||
}
|
||||
|
||||
async #getAllByOffset(value: string, direction: 1 | -1, limit?: number) {
|
||||
if (direction === 1) {
|
||||
return this.db.getAll(this.name, IDBKeyRange.lowerBound(value), limit);
|
||||
}
|
||||
return this.#getAllByDescOffset(value, limit);
|
||||
}
|
||||
|
||||
async #getAllByDescOffset(value: string, limit?: number) {
|
||||
if (limit === undefined) {
|
||||
return this.db.getAll(this.name, IDBKeyRange.upperBound(value));
|
||||
}
|
||||
const result = [];
|
||||
let cursor = await this.db
|
||||
.transaction(this.name, "readonly")
|
||||
.store.openCursor(IDBKeyRange.upperBound(value), "prev");
|
||||
for (let i = 0; i < limit; i++) {
|
||||
if (cursor === null) {
|
||||
break;
|
||||
}
|
||||
result.push(cursor.value);
|
||||
cursor = await cursor.continue();
|
||||
}
|
||||
return result.reverse();
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Update
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async updateOne(filter: Filter<WithId<TSchema>>, operators: UpdateFilter<TSchema>): Promise<UpdateResult> {
|
||||
if (typeof filter.id === "string") {
|
||||
return this.#update(filter.id, filter, operators);
|
||||
}
|
||||
const documents = await this.find(filter);
|
||||
if (documents.length > 0) {
|
||||
return this.#update(documents[0].id, filter, operators);
|
||||
}
|
||||
return new UpdateResult(0, 0);
|
||||
}
|
||||
|
||||
async updateMany(filter: Filter<WithId<TSchema>>, operators: UpdateFilter<TSchema>): Promise<UpdateResult> {
|
||||
const logger = new UpdateLog(this.name, { filter, operators });
|
||||
|
||||
const ids = await this.find(filter).then((data) => data.map((d) => d.id));
|
||||
|
||||
const documents: WithId<TSchema>[] = [];
|
||||
let modifiedCount = 0;
|
||||
|
||||
const tx = this.db.transaction(this.name, "readwrite", { durability: "relaxed" });
|
||||
await Promise.all(
|
||||
ids.map((id) =>
|
||||
tx.store.get(id).then((current) => {
|
||||
if (current === undefined) {
|
||||
return;
|
||||
}
|
||||
const { modified, document } = update<TSchema>(filter, operators, current);
|
||||
if (modified) {
|
||||
modifiedCount += 1;
|
||||
documents.push(document);
|
||||
return tx.store.put(document);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await tx.done;
|
||||
|
||||
this.broadcast("updateMany", documents);
|
||||
this.#cache.flush();
|
||||
|
||||
this.log(logger.result());
|
||||
|
||||
return new UpdateResult(ids.length, modifiedCount);
|
||||
}
|
||||
|
||||
async replace(filter: Filter<WithId<TSchema>>, document: TSchema): Promise<UpdateResult> {
|
||||
const logger = new ReplaceLog(this.name, document);
|
||||
|
||||
const ids = await this.find(filter).then((data) => data.map((d) => d.id));
|
||||
|
||||
const documents: WithId<TSchema>[] = [];
|
||||
const count = ids.length;
|
||||
|
||||
const tx = this.db.transaction(this.name, "readwrite", { durability: "relaxed" });
|
||||
await Promise.all(
|
||||
ids.map((id) => {
|
||||
const next = { ...document, id };
|
||||
documents.push(next);
|
||||
return tx.store.put(next);
|
||||
})
|
||||
);
|
||||
await tx.done;
|
||||
|
||||
this.broadcast("updateMany", documents);
|
||||
this.#cache.flush();
|
||||
|
||||
this.log(logger.result({ count }));
|
||||
|
||||
return new UpdateResult(count, count);
|
||||
}
|
||||
|
||||
async #update(
|
||||
id: string | number,
|
||||
filter: Filter<WithId<TSchema>>,
|
||||
operators: UpdateFilter<TSchema>
|
||||
): Promise<UpdateResult> {
|
||||
const logger = new UpdateLog(this.name, { filter, operators });
|
||||
|
||||
const tx = this.db.transaction(this.name, "readwrite", { durability: "relaxed" });
|
||||
|
||||
const current = await tx.store.get(id);
|
||||
if (current === undefined) {
|
||||
await tx.done;
|
||||
return new UpdateResult(0, 0);
|
||||
}
|
||||
|
||||
const { modified, document } = await update<TSchema>(filter, operators, current);
|
||||
if (modified === true) {
|
||||
await tx.store.put(document);
|
||||
}
|
||||
await tx.done;
|
||||
|
||||
if (modified === true) {
|
||||
this.broadcast("updateOne", document);
|
||||
this.log(logger.result());
|
||||
this.#cache.flush();
|
||||
return new UpdateResult(1, 1);
|
||||
}
|
||||
|
||||
return new UpdateResult(1);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Remove
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async remove(filter: Filter<WithId<TSchema>>): Promise<RemoveResult> {
|
||||
const logger = new RemoveLog(this.name, { filter });
|
||||
|
||||
const documents = await this.find(filter);
|
||||
const tx = this.db.transaction(this.name, "readwrite");
|
||||
|
||||
await Promise.all(documents.map((data) => tx.store.delete(data.id)));
|
||||
await tx.done;
|
||||
|
||||
this.broadcast("remove", documents);
|
||||
this.#cache.flush();
|
||||
|
||||
this.log(logger.result({ count: documents.length }));
|
||||
|
||||
return new RemoveResult(documents.length);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Count
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async count(filter?: Filter<WithId<TSchema>>): Promise<number> {
|
||||
if (filter !== undefined) {
|
||||
return (await this.find(filter)).length;
|
||||
}
|
||||
return this.db.count(this.name);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Flush
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.db.clear(this.name);
|
||||
this.#cache.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utils
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export function isObject(v: AnyVal): v is object {
|
||||
if (!v) {
|
||||
return false;
|
||||
}
|
||||
const proto = Object.getPrototypeOf(v) as AnyVal;
|
||||
return (proto === OBJECT_PROTOTYPE || proto === null) && OBJECT_TAG === Object.prototype.toString.call(v);
|
||||
}
|
||||
74
src/Databases/IndexedDb.ts
Normal file
74
src/Databases/IndexedDb.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { IDBPDatabase, openDB } from "idb/with-async-ittr";
|
||||
|
||||
import { Collection } from "../Collection.js";
|
||||
import { DBLogger } from "../Logger.js";
|
||||
import { Document } from "../Types.js";
|
||||
import { IndexedDbStorage } from "./IndexedDb.Storage.js";
|
||||
import { Registrars } from "./Registrars.js";
|
||||
|
||||
function log() {}
|
||||
|
||||
type StringRecord<T> = { [x: string]: T };
|
||||
|
||||
type Options = {
|
||||
name: string;
|
||||
version?: number;
|
||||
registrars: Registrars[];
|
||||
log?: DBLogger;
|
||||
};
|
||||
|
||||
export class IndexedDatabase<T extends StringRecord<Document>> {
|
||||
readonly #collections = new Map<keyof T, Collection<T[keyof T]>>();
|
||||
readonly #db: Promise<IDBPDatabase<unknown>>;
|
||||
|
||||
constructor(readonly options: Options) {
|
||||
this.#db = openDB(options.name, options.version ?? 1, {
|
||||
upgrade: (db: IDBPDatabase) => {
|
||||
for (const { name, indexes = [] } of options.registrars) {
|
||||
const store = db.createObjectStore(name as string, { keyPath: "id" });
|
||||
store.createIndex("id", "id", { unique: true });
|
||||
for (const [keyPath, options] of indexes) {
|
||||
store.createIndex(keyPath, keyPath, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
for (const { name } of options.registrars) {
|
||||
this.#collections.set(name, new Collection(name, new IndexedDbStorage(name, this.#db, options.log ?? log)));
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Fetchers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
collection<TSchema extends T[Name], Name extends keyof T = keyof T>(name: Name): Collection<TSchema> {
|
||||
const collection = this.#collections.get(name);
|
||||
if (collection === undefined) {
|
||||
throw new Error(`Collection '${name as string}' not found`);
|
||||
}
|
||||
return collection as any;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utilities
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async export(name: string, options?: { offset?: string; limit?: number }): Promise<any[]> {
|
||||
return (await this.#db).getAll(name, options?.offset, options?.limit) ?? [];
|
||||
}
|
||||
|
||||
async flush() {
|
||||
for (const collection of this.#collections.values()) {
|
||||
collection.flush();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.#db.then((db) => db.close());
|
||||
}
|
||||
}
|
||||
149
src/Databases/MemoryDb.Storage.ts
Normal file
149
src/Databases/MemoryDb.Storage.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Query } from "mingo";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import {
|
||||
addOptions,
|
||||
DuplicateDocumentError,
|
||||
getInsertManyResult,
|
||||
getInsertOneResult,
|
||||
InsertManyResult,
|
||||
InsertOneResult,
|
||||
Options,
|
||||
RemoveResult,
|
||||
Storage,
|
||||
update,
|
||||
UpdateResult
|
||||
} from "../Storage/mod.js";
|
||||
import { Document, Filter, UpdateFilter, WithId } from "../Types.js";
|
||||
|
||||
export class MemoryStorage<TSchema extends Document = Document> extends Storage<TSchema> {
|
||||
readonly #documents = new Map<string, WithId<TSchema>>();
|
||||
|
||||
async resolve() {
|
||||
return this;
|
||||
}
|
||||
|
||||
async has(id: string): Promise<boolean> {
|
||||
return this.#documents.has(id);
|
||||
}
|
||||
|
||||
async insertOne(data: Partial<TSchema>): Promise<InsertOneResult> {
|
||||
const document = { ...data, id: data.id ?? nanoid() } as WithId<TSchema>;
|
||||
if (await this.has(document.id)) {
|
||||
throw new DuplicateDocumentError(document, this as any);
|
||||
}
|
||||
this.#documents.set(document.id, document);
|
||||
this.broadcast("insertOne", document);
|
||||
return getInsertOneResult(document);
|
||||
}
|
||||
|
||||
async insertMany(documents: Partial<TSchema>[]): Promise<InsertManyResult> {
|
||||
const result: TSchema[] = [];
|
||||
for (const data of documents) {
|
||||
const document = { ...data, id: data.id ?? nanoid() } as WithId<TSchema>;
|
||||
result.push(document);
|
||||
this.#documents.set(document.id, document);
|
||||
}
|
||||
|
||||
this.broadcast("insertMany", result);
|
||||
|
||||
return getInsertManyResult(result);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WithId<TSchema> | undefined> {
|
||||
return this.#documents.get(id);
|
||||
}
|
||||
|
||||
async find(filter?: Filter<WithId<TSchema>>, options?: Options): Promise<WithId<TSchema>[]> {
|
||||
let cursor = new Query(filter ?? {}).find(Array.from(this.#documents.values()));
|
||||
if (options !== undefined) {
|
||||
cursor = addOptions(cursor, options);
|
||||
}
|
||||
return cursor.all() as WithId<TSchema>[];
|
||||
}
|
||||
|
||||
async updateOne(filter: Filter<WithId<TSchema>>, operators: UpdateFilter<TSchema>): Promise<UpdateResult> {
|
||||
const query = new Query(filter);
|
||||
for (const current of Array.from(this.#documents.values())) {
|
||||
if (query.test(current) === true) {
|
||||
const { modified, document } = update<TSchema>(filter, operators, current);
|
||||
if (modified === true) {
|
||||
this.#documents.set(document.id, document);
|
||||
this.broadcast("updateOne", document);
|
||||
return new UpdateResult(1, 1);
|
||||
}
|
||||
return new UpdateResult(1, 0);
|
||||
}
|
||||
}
|
||||
return new UpdateResult(0, 0);
|
||||
}
|
||||
|
||||
async updateMany(filter: Filter<WithId<TSchema>>, operators: UpdateFilter<TSchema>): Promise<UpdateResult> {
|
||||
const query = new Query(filter);
|
||||
|
||||
const documents: WithId<TSchema>[] = [];
|
||||
|
||||
let matchedCount = 0;
|
||||
let modifiedCount = 0;
|
||||
|
||||
for (const current of Array.from(this.#documents.values())) {
|
||||
if (query.test(current) === true) {
|
||||
matchedCount += 1;
|
||||
const { modified, document } = update<TSchema>(filter, operators, current);
|
||||
if (modified === true) {
|
||||
modifiedCount += 1;
|
||||
documents.push(document);
|
||||
this.#documents.set(document.id, document);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.broadcast("updateMany", documents);
|
||||
|
||||
return new UpdateResult(matchedCount, modifiedCount);
|
||||
}
|
||||
|
||||
async replace(filter: Filter<WithId<TSchema>>, document: WithId<TSchema>): Promise<UpdateResult> {
|
||||
const query = new Query(filter);
|
||||
|
||||
const documents: WithId<TSchema>[] = [];
|
||||
|
||||
let matchedCount = 0;
|
||||
let modifiedCount = 0;
|
||||
|
||||
for (const current of Array.from(this.#documents.values())) {
|
||||
if (query.test(current) === true) {
|
||||
matchedCount += 1;
|
||||
modifiedCount += 1;
|
||||
documents.push(document);
|
||||
this.#documents.set(document.id, document);
|
||||
}
|
||||
}
|
||||
|
||||
this.broadcast("updateMany", documents);
|
||||
|
||||
return new UpdateResult(matchedCount, modifiedCount);
|
||||
}
|
||||
|
||||
async remove(filter: Filter<WithId<TSchema>>): Promise<RemoveResult> {
|
||||
const documents = Array.from(this.#documents.values());
|
||||
const query = new Query(filter);
|
||||
let count = 0;
|
||||
for (const document of documents) {
|
||||
if (query.test(document) === true) {
|
||||
this.#documents.delete(document.id);
|
||||
this.broadcast("remove", document);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return new RemoveResult(count);
|
||||
}
|
||||
|
||||
async count(filter?: Filter<WithId<TSchema>>): Promise<number> {
|
||||
return new Query(filter ?? {}).find(Array.from(this.#documents.values())).count();
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
this.#documents.clear();
|
||||
}
|
||||
}
|
||||
40
src/Databases/MemoryDb.ts
Normal file
40
src/Databases/MemoryDb.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Collection } from "../Collection.js";
|
||||
import { Document } from "../Types.js";
|
||||
import { MemoryStorage } from "./MemoryDb.Storage.js";
|
||||
import { Registrars } from "./Registrars.js";
|
||||
|
||||
export class MemoryDatabase<T extends Record<string, Document>> {
|
||||
readonly #collections = new Map<keyof T, Collection<T[keyof T]>>();
|
||||
|
||||
register(registrars: Registrars[]): void {
|
||||
for (const { name } of registrars) {
|
||||
this.#collections.set(name, new Collection(name, new MemoryStorage(name)));
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Fetchers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
collection<Name extends keyof T>(name: Name): Collection<T[Name]> {
|
||||
const collection = this.#collections.get(name);
|
||||
if (collection === undefined) {
|
||||
throw new Error(`Collection '${name as string}' not found`);
|
||||
}
|
||||
return collection as any;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utilities
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async flush() {
|
||||
for (const collection of this.#collections.values()) {
|
||||
collection.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
139
src/Databases/Observer.Storage.ts
Normal file
139
src/Databases/Observer.Storage.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Query } from "mingo";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import {
|
||||
addOptions,
|
||||
DuplicateDocumentError,
|
||||
getInsertManyResult,
|
||||
getInsertOneResult,
|
||||
InsertManyResult,
|
||||
InsertOneResult,
|
||||
Options,
|
||||
RemoveResult,
|
||||
Storage,
|
||||
update,
|
||||
UpdateResult
|
||||
} from "../Storage/mod.js";
|
||||
import { Document, Filter, UpdateFilter, WithId } from "../Types.js";
|
||||
|
||||
export class ObserverStorage<TSchema extends Document = Document> extends Storage<TSchema> {
|
||||
readonly #documents = new Map<string, WithId<TSchema>>();
|
||||
|
||||
async resolve() {
|
||||
return this;
|
||||
}
|
||||
|
||||
async has(id: string): Promise<boolean> {
|
||||
return this.#documents.has(id);
|
||||
}
|
||||
|
||||
async insertOne(data: Partial<TSchema>): Promise<InsertOneResult> {
|
||||
const document = { ...data, id: data.id ?? nanoid() } as WithId<TSchema>;
|
||||
if (await this.has(document.id)) {
|
||||
throw new DuplicateDocumentError(document, this as any);
|
||||
}
|
||||
this.#documents.set(document.id, document);
|
||||
return getInsertOneResult(document);
|
||||
}
|
||||
|
||||
async insertMany(documents: Partial<TSchema>[]): Promise<InsertManyResult> {
|
||||
const result: TSchema[] = [];
|
||||
for (const data of documents) {
|
||||
const document = { ...data, id: data.id ?? nanoid() } as WithId<TSchema>;
|
||||
result.push(document);
|
||||
this.#documents.set(document.id, document);
|
||||
}
|
||||
return getInsertManyResult(result);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WithId<TSchema> | undefined> {
|
||||
return this.#documents.get(id);
|
||||
}
|
||||
|
||||
async find(filter?: Filter<WithId<TSchema>>, options?: Options): Promise<WithId<TSchema>[]> {
|
||||
let cursor = new Query(filter ?? {}).find(Array.from(this.#documents.values()));
|
||||
if (options !== undefined) {
|
||||
cursor = addOptions(cursor, options);
|
||||
}
|
||||
return cursor.all() as WithId<TSchema>[];
|
||||
}
|
||||
|
||||
async updateOne(filter: Filter<WithId<TSchema>>, operators: UpdateFilter<TSchema>): Promise<UpdateResult> {
|
||||
const query = new Query(filter);
|
||||
for (const current of Array.from(this.#documents.values())) {
|
||||
if (query.test(current) === true) {
|
||||
const { modified, document } = update<TSchema>(filter, operators, current);
|
||||
if (modified === true) {
|
||||
this.#documents.set(document.id, document);
|
||||
return new UpdateResult(1, 1);
|
||||
}
|
||||
return new UpdateResult(1, 0);
|
||||
}
|
||||
}
|
||||
return new UpdateResult(0, 0);
|
||||
}
|
||||
|
||||
async updateMany(filter: Filter<WithId<TSchema>>, operators: UpdateFilter<TSchema>): Promise<UpdateResult> {
|
||||
const query = new Query(filter);
|
||||
|
||||
const documents: WithId<TSchema>[] = [];
|
||||
|
||||
let matchedCount = 0;
|
||||
let modifiedCount = 0;
|
||||
|
||||
for (const current of Array.from(this.#documents.values())) {
|
||||
if (query.test(current) === true) {
|
||||
matchedCount += 1;
|
||||
const { modified, document } = update<TSchema>(filter, operators, current);
|
||||
if (modified === true) {
|
||||
modifiedCount += 1;
|
||||
documents.push(document);
|
||||
this.#documents.set(document.id, document);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new UpdateResult(matchedCount, modifiedCount);
|
||||
}
|
||||
|
||||
async replace(filter: Filter<WithId<TSchema>>, document: WithId<TSchema>): Promise<UpdateResult> {
|
||||
const query = new Query(filter);
|
||||
|
||||
const documents: WithId<TSchema>[] = [];
|
||||
|
||||
let matchedCount = 0;
|
||||
let modifiedCount = 0;
|
||||
|
||||
for (const current of Array.from(this.#documents.values())) {
|
||||
if (query.test(current) === true) {
|
||||
matchedCount += 1;
|
||||
modifiedCount += 1;
|
||||
documents.push(document);
|
||||
this.#documents.set(document.id, document);
|
||||
}
|
||||
}
|
||||
|
||||
return new UpdateResult(matchedCount, modifiedCount);
|
||||
}
|
||||
|
||||
async remove(filter: Filter<WithId<TSchema>>): Promise<RemoveResult> {
|
||||
const documents = Array.from(this.#documents.values());
|
||||
const query = new Query(filter);
|
||||
let count = 0;
|
||||
for (const document of documents) {
|
||||
if (query.test(document) === true) {
|
||||
this.#documents.delete(document.id);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return new RemoveResult(count);
|
||||
}
|
||||
|
||||
async count(filter?: Filter<WithId<TSchema>>): Promise<number> {
|
||||
return new Query(filter ?? {}).find(Array.from(this.#documents.values())).count();
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
this.#documents.clear();
|
||||
}
|
||||
}
|
||||
6
src/Databases/Registrars.ts
Normal file
6
src/Databases/Registrars.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type Registrars = {
|
||||
name: string;
|
||||
indexes?: Index[];
|
||||
};
|
||||
|
||||
type Index = [string, IDBIndexParameters?];
|
||||
2
src/Databases/mod.ts
Normal file
2
src/Databases/mod.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./IndexedDb.js";
|
||||
export * from "./MemoryDb.js";
|
||||
12
src/Hash.ts
Normal file
12
src/Hash.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function hashCodeQuery(filter: unknown, options: unknown): number {
|
||||
const value = JSON.stringify({ filter, options });
|
||||
let hash = 0;
|
||||
if (value.length === 0) {
|
||||
return hash;
|
||||
}
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash = (hash << 5) - hash + value.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
67
src/Logger.ts
Normal file
67
src/Logger.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
class Performance {
|
||||
startedAt = performance.now();
|
||||
endedAt?: number;
|
||||
duration?: number;
|
||||
|
||||
result() {
|
||||
this.endedAt = performance.now();
|
||||
this.duration = Number((this.endedAt - this.startedAt).toFixed(2));
|
||||
}
|
||||
}
|
||||
|
||||
abstract class LogEvent {
|
||||
readonly performance = new Performance();
|
||||
|
||||
data?: Record<string, any>;
|
||||
|
||||
constructor(readonly collection: string, readonly query?: Record<string, any>) {}
|
||||
|
||||
result(data?: Record<string, any>): this {
|
||||
this.performance.result();
|
||||
this.data = data;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Loggers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export class InsertLog extends LogEvent implements DBLogEvent {
|
||||
readonly type = "insert" as const;
|
||||
}
|
||||
|
||||
export class UpdateLog extends LogEvent implements DBLogEvent {
|
||||
readonly type = "update" as const;
|
||||
}
|
||||
|
||||
export class ReplaceLog extends LogEvent implements DBLogEvent {
|
||||
readonly type = "replace" as const;
|
||||
}
|
||||
|
||||
export class RemoveLog extends LogEvent implements DBLogEvent {
|
||||
readonly type = "remove" as const;
|
||||
}
|
||||
|
||||
export class QueryLog extends LogEvent implements DBLogEvent {
|
||||
readonly type = "query" as const;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type DBLogger = (event: DBLogEvent) => void;
|
||||
|
||||
export type DBLogEvent = {
|
||||
type: DBLogEventType;
|
||||
collection: string;
|
||||
performance: Performance;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type DBLogEventType = InsertLog["type"] | UpdateLog["type"] | ReplaceLog["type"] | RemoveLog["type"] | QueryLog["type"];
|
||||
13
src/Observe/Action.ts
Normal file
13
src/Observe/Action.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type Action<T = unknown> =
|
||||
| {
|
||||
type: "insert";
|
||||
instance: T;
|
||||
}
|
||||
| {
|
||||
type: "update";
|
||||
instance: T;
|
||||
}
|
||||
| {
|
||||
type: "remove";
|
||||
instance: T;
|
||||
};
|
||||
10
src/Observe/IsMatch.ts
Normal file
10
src/Observe/IsMatch.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Query } from "mingo";
|
||||
|
||||
import { Document, Filter, WithId } from "../Types.js";
|
||||
|
||||
export function isMatch<TSchema extends Document = Document>(
|
||||
document: WithId<TSchema>,
|
||||
filter?: Filter<WithId<TSchema>>
|
||||
): boolean {
|
||||
return !filter || new Query(filter).test(document);
|
||||
}
|
||||
75
src/Observe/Observe.ts
Normal file
75
src/Observe/Observe.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Query } from "mingo";
|
||||
|
||||
import { Collection } from "../Collection.js";
|
||||
import { addOptions, ChangeEvent, Options } from "../Storage/mod.js";
|
||||
import { Document, Filter, WithId } from "../Types.js";
|
||||
import { Store } from "./Store.js";
|
||||
|
||||
export function observe<TSchema extends Document = Document>(
|
||||
collection: Collection<TSchema>,
|
||||
filter: Filter<WithId<TSchema>>,
|
||||
options: Options | undefined,
|
||||
onChange: (documents: WithId<TSchema>[], changed: WithId<TSchema>[], type: ChangeEvent<TSchema>["type"]) => void
|
||||
): {
|
||||
unsubscribe: () => void;
|
||||
} {
|
||||
const store = Store.create<TSchema>();
|
||||
|
||||
let debounce: NodeJS.Timeout;
|
||||
|
||||
collection.find(filter, options).then(async (documents) => {
|
||||
const resolved = await store.resolve(documents);
|
||||
onChange(resolved, resolved, "insertMany");
|
||||
});
|
||||
|
||||
const subscriptions = [
|
||||
collection.observable.flush.subscribe(() => {
|
||||
clearTimeout(debounce);
|
||||
store.flush();
|
||||
onChange([], [], "remove");
|
||||
}),
|
||||
collection.observable.change.subscribe(async ({ type, data }) => {
|
||||
let changed: WithId<TSchema>[] = [];
|
||||
switch (type) {
|
||||
case "insertOne":
|
||||
case "updateOne": {
|
||||
changed = await store[type](data, filter);
|
||||
break;
|
||||
}
|
||||
case "insertMany":
|
||||
case "updateMany":
|
||||
case "remove": {
|
||||
changed = await store[type](data, filter);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (changed.length > 0) {
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(() => {
|
||||
store.getDocuments().then((documents) => {
|
||||
onChange(applyQueryOptions(documents, options), changed, type);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
})
|
||||
];
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
store.destroy();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function applyQueryOptions<TSchema extends Document = Document>(
|
||||
documents: WithId<TSchema>[],
|
||||
options?: Options
|
||||
): WithId<TSchema>[] {
|
||||
if (options !== undefined) {
|
||||
return addOptions(new Query({}).find(documents), options).all() as WithId<TSchema>[];
|
||||
}
|
||||
return documents;
|
||||
}
|
||||
40
src/Observe/ObserveOne.ts
Normal file
40
src/Observe/ObserveOne.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Collection } from "../Collection.js";
|
||||
import { Document, Filter, WithId } from "../Types.js";
|
||||
import { isMatch } from "./IsMatch.js";
|
||||
|
||||
export function observeOne<TSchema extends Document = Document>(
|
||||
collection: Collection<TSchema>,
|
||||
filter: Filter<WithId<TSchema>>,
|
||||
onChange: (document: Document | undefined) => void
|
||||
): {
|
||||
unsubscribe: () => void;
|
||||
} {
|
||||
collection.findOne(filter).then(onChange);
|
||||
|
||||
const subscription = collection.observable.change.subscribe(({ type, data }) => {
|
||||
switch (type) {
|
||||
case "insertOne":
|
||||
case "updateOne": {
|
||||
if (isMatch<TSchema>(data, filter) === true) {
|
||||
onChange(data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "remove": {
|
||||
for (const document of data) {
|
||||
if (isMatch<TSchema>(document, filter) === true) {
|
||||
onChange(undefined);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
};
|
||||
}
|
||||
85
src/Observe/Store.ts
Normal file
85
src/Observe/Store.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { ObserverStorage } from "../Databases/Observer.Storage.js";
|
||||
import { Storage } from "../Storage/mod.js";
|
||||
import { Document, Filter, WithId } from "../Types.js";
|
||||
import { isMatch } from "./IsMatch.js";
|
||||
|
||||
export class Store<TSchema extends Document = Document> {
|
||||
private constructor(private storage: Storage<TSchema>) {}
|
||||
|
||||
static create<TSchema extends Document = Document>() {
|
||||
return new Store<TSchema>(new ObserverStorage<TSchema>(`observer[${nanoid()}]`));
|
||||
}
|
||||
|
||||
get destroy() {
|
||||
return this.storage.destroy.bind(this.storage);
|
||||
}
|
||||
|
||||
async resolve(documents: WithId<TSchema>[]): Promise<WithId<TSchema>[]> {
|
||||
await this.storage.insertMany(documents);
|
||||
return this.getDocuments();
|
||||
}
|
||||
|
||||
async getDocuments(): Promise<WithId<TSchema>[]> {
|
||||
return this.storage.find();
|
||||
}
|
||||
|
||||
async insertMany(documents: WithId<TSchema>[], filter: Filter<WithId<TSchema>>): Promise<WithId<TSchema>[]> {
|
||||
const matched = [];
|
||||
for (const document of documents) {
|
||||
matched.push(...(await this.insertOne(document, filter)));
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
async insertOne(document: WithId<TSchema>, filter: Filter<WithId<TSchema>>): Promise<WithId<TSchema>[]> {
|
||||
if (isMatch<TSchema>(document, filter)) {
|
||||
await this.storage.insertOne(document);
|
||||
return [document];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async updateMany(documents: WithId<TSchema>[], filter: Filter<WithId<TSchema>>): Promise<WithId<TSchema>[]> {
|
||||
const matched = [];
|
||||
for (const document of documents) {
|
||||
matched.push(...(await this.updateOne(document, filter)));
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
async updateOne(document: WithId<TSchema>, filter: Filter<WithId<TSchema>>): Promise<WithId<TSchema>[]> {
|
||||
if (await this.storage.has(document.id)) {
|
||||
await this.#updateOrRemove(document, filter);
|
||||
return [document];
|
||||
} else if (isMatch<TSchema>(document, filter)) {
|
||||
await this.storage.insertOne(document);
|
||||
return [document];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async remove(documents: WithId<TSchema>[]): Promise<WithId<TSchema>[]> {
|
||||
const matched = [];
|
||||
for (const document of documents) {
|
||||
if (isMatch<TSchema>(document, { id: document.id } as WithId<TSchema>)) {
|
||||
await this.storage.remove({ id: document.id } as WithId<TSchema>);
|
||||
matched.push(document);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
async #updateOrRemove(document: WithId<TSchema>, filter: Filter<WithId<TSchema>>): Promise<void> {
|
||||
if (isMatch<TSchema>(document, filter)) {
|
||||
await this.storage.replace({ id: document.id } as WithId<TSchema>, document);
|
||||
} else {
|
||||
await this.storage.remove({ id: document.id } as WithId<TSchema>);
|
||||
}
|
||||
}
|
||||
|
||||
flush() {
|
||||
this.storage.flush();
|
||||
}
|
||||
}
|
||||
3
src/Observe/mod.ts
Normal file
3
src/Observe/mod.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type { Action } from "./Action.js";
|
||||
export * from "./Observe.js";
|
||||
export * from "./ObserveOne.js";
|
||||
30
src/Storage/Errors.ts
Normal file
30
src/Storage/Errors.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { RawObject } from "mingo/types";
|
||||
|
||||
import { Document } from "../Types.js";
|
||||
import type { Storage } from "./Storage.js";
|
||||
|
||||
export class DuplicateDocumentError extends Error {
|
||||
readonly type = "DuplicateDocumentError";
|
||||
|
||||
constructor(readonly document: Document, storage: Storage) {
|
||||
super(
|
||||
`Collection Insert Violation: Document '${document.id}' already exists in ${storage.name} collection ${storage.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class DocumentNotFoundError extends Error {
|
||||
readonly type = "DocumentNotFoundError";
|
||||
|
||||
constructor(readonly criteria: RawObject) {
|
||||
super(`Collection Update Violation: Document matching criteria does not exists`);
|
||||
}
|
||||
}
|
||||
|
||||
export class PullUpdateArrayError extends Error {
|
||||
readonly type = "PullUpdateArrayError";
|
||||
|
||||
constructor(document: string, key: string) {
|
||||
super(`Collection Update Violation: Document '${document}' $pull operation failed, '${key}' is not an array`);
|
||||
}
|
||||
}
|
||||
40
src/Storage/Operators/Insert/Result.ts
Normal file
40
src/Storage/Operators/Insert/Result.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Document } from "../../../Types.js";
|
||||
|
||||
export function getInsertManyResult(documents: Document[]): InsertManyResult {
|
||||
return {
|
||||
acknowledged: true,
|
||||
insertedCount: documents.length,
|
||||
insertedIds: documents.reduce<{ [key: number]: string | number }>((map, document, index) => {
|
||||
map[index] = document.id;
|
||||
return map;
|
||||
}, {})
|
||||
};
|
||||
}
|
||||
|
||||
export function getInsertOneResult(document: Document): InsertOneResult {
|
||||
return {
|
||||
acknowledged: true,
|
||||
insertedId: document.id
|
||||
};
|
||||
}
|
||||
|
||||
export type InsertManyResult =
|
||||
| {
|
||||
acknowledged: false;
|
||||
}
|
||||
| {
|
||||
acknowledged: true;
|
||||
insertedCount: number;
|
||||
insertedIds: {
|
||||
[key: number]: string | number;
|
||||
};
|
||||
};
|
||||
|
||||
export type InsertOneResult =
|
||||
| {
|
||||
acknowledged: false;
|
||||
}
|
||||
| {
|
||||
acknowledged: true;
|
||||
insertedId: string | number;
|
||||
};
|
||||
1
src/Storage/Operators/Insert/mod.ts
Normal file
1
src/Storage/Operators/Insert/mod.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./Result.js";
|
||||
3
src/Storage/Operators/Remove/Result.ts
Normal file
3
src/Storage/Operators/Remove/Result.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export class RemoveResult {
|
||||
constructor(readonly matched = 0) {}
|
||||
}
|
||||
1
src/Storage/Operators/Remove/mod.ts
Normal file
1
src/Storage/Operators/Remove/mod.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./Result.js";
|
||||
56
src/Storage/Operators/Update/Inc.ts
Normal file
56
src/Storage/Operators/Update/Inc.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as dot from "dot-prop";
|
||||
|
||||
import { Document, Filter, UpdateFilter, WithId } from "../../../Types.js";
|
||||
import { setPositionalData } from "./Utils.js";
|
||||
|
||||
/**
|
||||
* Execute a $inc based operators.
|
||||
*
|
||||
* Supports positional array operator $(update)
|
||||
*
|
||||
* @see https://www.mongodb.com/docs/manual/reference/operator/update/positional
|
||||
*
|
||||
* @param document - Document being updated.
|
||||
* @param filter - Search filter provided with the operation. Eg. updateOne({ id: "1" })
|
||||
* @param $set - $set action being executed.
|
||||
*/
|
||||
export function $inc<TSchema extends Document = Document>(
|
||||
document: WithId<TSchema>,
|
||||
filter: Filter<WithId<TSchema>>,
|
||||
$inc: UpdateFilter<TSchema>["$inc"] = {}
|
||||
): boolean {
|
||||
let modified = false;
|
||||
for (const key in $inc) {
|
||||
if (key.includes("$")) {
|
||||
if (
|
||||
setPositionalData(document, filter, key, {
|
||||
object: (data, key, target) => {
|
||||
if (typeof data === "number") {
|
||||
return data + ($inc[key] as number);
|
||||
}
|
||||
const value = dot.getProperty(data, target);
|
||||
if (typeof value !== "number") {
|
||||
return 0;
|
||||
}
|
||||
return value + $inc[key];
|
||||
},
|
||||
value: (data, key) => data + $inc[key]
|
||||
})
|
||||
) {
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
document = increment(document, key, $inc[key]);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
}
|
||||
|
||||
function increment<D extends Document>(document: D, key: string, value: number): D {
|
||||
let currentValue = dot.getProperty(document, key) as unknown;
|
||||
if (typeof currentValue !== "number") {
|
||||
currentValue = 0;
|
||||
}
|
||||
return dot.setProperty(document, key, (currentValue as number) + value);
|
||||
}
|
||||
60
src/Storage/Operators/Update/Pull.ts
Normal file
60
src/Storage/Operators/Update/Pull.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as dot from "dot-prop";
|
||||
import { Query } from "mingo";
|
||||
import { RawObject } from "mingo/types";
|
||||
|
||||
import { Document, UpdateFilter, WithId } from "../../../Types.js";
|
||||
import { PullUpdateArrayError } from "../../Errors.js";
|
||||
|
||||
export function $pull<TSchema extends Document>(
|
||||
document: WithId<TSchema>,
|
||||
operator: UpdateFilter<TSchema>["$pull"] = {}
|
||||
): boolean {
|
||||
let modified = false;
|
||||
for (const key in operator) {
|
||||
const values = getPullValues(document, key);
|
||||
const result = getPullResult(operator, key, values);
|
||||
dot.setProperty(document, key, result);
|
||||
if (result.length !== values.length) {
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
}
|
||||
|
||||
function getPullValues(document: Document, key: string): any[] {
|
||||
const values: any[] | undefined = dot.getProperty(document, key);
|
||||
if (values === undefined || Array.isArray(values) === false) {
|
||||
throw new PullUpdateArrayError(document.id, key);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function getPullResult(operator: RawObject, key: string, values: any[]): any[] {
|
||||
if (typeof operator[key] === "object") {
|
||||
return new Query(getPullCriteria(operator, key)).remove(values);
|
||||
}
|
||||
return values.filter((value) => value !== operator[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Criteria used during pull depends on the structure of the query under the pulled
|
||||
* key. If the object has a mongodb filter key with a $ prefix we need to provide
|
||||
* the query with the array key as the query wrapper. If a $ prefix is not present
|
||||
* we want the value under the key being the criteria.
|
||||
*
|
||||
* @param operator - Object under operator action.
|
||||
* @param key - Specific key being resolved to a criteria.
|
||||
*/
|
||||
function getPullCriteria(operator: any, key: string): RawObject {
|
||||
let hasFilters = false;
|
||||
for (const left in operator[key]) {
|
||||
if (left.includes("$")) {
|
||||
hasFilters = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasFilters === true) {
|
||||
return { [key]: dot.getProperty(operator, key) };
|
||||
}
|
||||
return dot.getProperty(operator, key) as RawObject;
|
||||
}
|
||||
72
src/Storage/Operators/Update/Push.ts
Normal file
72
src/Storage/Operators/Update/Push.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as dot from "dot-prop";
|
||||
import { deepEqual } from "fast-equals";
|
||||
import { Query } from "mingo";
|
||||
import type { RawObject } from "mingo/types";
|
||||
|
||||
import { Document, UpdateFilter, WithId } from "../../../Types.js";
|
||||
|
||||
export function $push<TSchema extends Document = Document>(
|
||||
document: WithId<TSchema>,
|
||||
operator: UpdateFilter<TSchema>["$push"] = {}
|
||||
): boolean {
|
||||
let modified = false;
|
||||
for (const key in operator) {
|
||||
const values = getPushValues(document, key);
|
||||
const result = getPushResult(operator, key, values);
|
||||
dot.setProperty(document, key, result);
|
||||
if (deepEqual(values, result) === false) {
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
}
|
||||
|
||||
function getPushValues(document: any, key: string): any[] {
|
||||
const values = dot.getProperty(document, key);
|
||||
if (values === undefined) {
|
||||
return [];
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function getPushResult(operator: RawObject, key: string, values: any[]): any[] {
|
||||
if (typeof operator[key] === "object") {
|
||||
return getPushFromModifiers(operator[key], values);
|
||||
}
|
||||
return [...values, operator[key]];
|
||||
}
|
||||
|
||||
function getPushFromModifiers(obj: any, values: any[]): any[] {
|
||||
if (obj.$each === undefined) {
|
||||
return [...values, obj];
|
||||
}
|
||||
let items: any[];
|
||||
|
||||
if (obj.$position !== undefined) {
|
||||
items = [...values.slice(0, obj.$position), ...obj.$each, ...values.slice(obj.$position)];
|
||||
} else {
|
||||
items = [...values, ...obj.$each];
|
||||
}
|
||||
|
||||
if (obj.$sort !== undefined) {
|
||||
if (typeof obj.$sort === "object") {
|
||||
items = new Query({}).find(items).sort(obj.$sort).all();
|
||||
} else {
|
||||
items = items.sort((a, b) => {
|
||||
if (obj.$sort === 1) {
|
||||
return a < b ? -1 : 1;
|
||||
}
|
||||
return a < b ? 1 : -1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.$slice !== undefined) {
|
||||
if (obj.$slice < 0) {
|
||||
return items.slice(obj.$slice);
|
||||
}
|
||||
return items.slice(0, obj.$slice);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
3
src/Storage/Operators/Update/Result.ts
Normal file
3
src/Storage/Operators/Update/Result.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export class UpdateResult {
|
||||
constructor(readonly matched = 0, readonly modified = 0) {}
|
||||
}
|
||||
47
src/Storage/Operators/Update/Set.ts
Normal file
47
src/Storage/Operators/Update/Set.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as dot from "dot-prop";
|
||||
|
||||
import { Document, Filter, UpdateFilter, WithId } from "../../../Types.js";
|
||||
import { setPositionalData } from "./Utils.js";
|
||||
|
||||
/**
|
||||
* Execute a $set based operators.
|
||||
*
|
||||
* Supports positional array operator $(update)
|
||||
*
|
||||
* @see https://www.mongodb.com/docs/manual/reference/operator/update/positional
|
||||
*
|
||||
* @param document - Document being updated.
|
||||
* @param filter - Search filter provided with the operation. Eg. updateOne({ id: "1" })
|
||||
* @param $set - $set action being executed.
|
||||
*/
|
||||
export function $set<TSchema extends Document = Document>(
|
||||
document: WithId<WithId<TSchema>>,
|
||||
filter: Filter<WithId<TSchema>>,
|
||||
$set: UpdateFilter<TSchema>["$set"] = {} as any
|
||||
): boolean {
|
||||
let modified = false;
|
||||
for (const key in $set) {
|
||||
if (key.includes("$")) {
|
||||
if (
|
||||
setPositionalData(document, filter, key, {
|
||||
object: (data, key) => getSetValue(data, key, $set),
|
||||
value: (_, key) => $set[key]
|
||||
})
|
||||
) {
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
document = dot.setProperty(document, key, getSetValue(document, key, $set));
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
}
|
||||
|
||||
function getSetValue(data: any, key: string, $set: UpdateFilter<Document>["$set"] = {}) {
|
||||
const value = $set[key];
|
||||
if (typeof value === "function") {
|
||||
return value(dot.getProperty(data, key), data);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
16
src/Storage/Operators/Update/Unset.ts
Normal file
16
src/Storage/Operators/Update/Unset.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as dot from "dot-prop";
|
||||
|
||||
import { Document, UpdateFilter, WithId } from "../../../Types.js";
|
||||
|
||||
export function $unset<TSchema extends Document = Document>(
|
||||
document: WithId<TSchema>,
|
||||
$unset: UpdateFilter<TSchema>["$unset"] = {}
|
||||
): boolean {
|
||||
let modified = false;
|
||||
for (const key in $unset) {
|
||||
if (dot.deleteProperty(document, key)) {
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
}
|
||||
26
src/Storage/Operators/Update/Update.ts
Normal file
26
src/Storage/Operators/Update/Update.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { clone } from "../../../Clone.js";
|
||||
import { Document, Filter, UpdateFilter, WithId } from "../../../Types.js";
|
||||
import { $inc } from "./Inc.js";
|
||||
import { $pull } from "./Pull.js";
|
||||
import { $push } from "./Push.js";
|
||||
import { $set } from "./Set.js";
|
||||
import { $unset } from "./Unset.js";
|
||||
|
||||
export function update<TSchema extends Document>(
|
||||
filter: Filter<WithId<TSchema>>,
|
||||
operators: UpdateFilter<TSchema>,
|
||||
document: WithId<TSchema>
|
||||
) {
|
||||
const updatedDocument = clone(document);
|
||||
|
||||
const setModified = $set<TSchema>(updatedDocument, filter, operators.$set);
|
||||
const runModified = $unset<TSchema>(updatedDocument, operators.$unset);
|
||||
const pushModified = $push<TSchema>(updatedDocument, operators.$push);
|
||||
const pullModified = $pull<TSchema>(updatedDocument, operators.$pull);
|
||||
const incModified = $inc<TSchema>(updatedDocument, filter, operators.$inc);
|
||||
|
||||
return {
|
||||
modified: setModified || runModified || pushModified || pullModified || incModified,
|
||||
document: updatedDocument
|
||||
};
|
||||
}
|
||||
168
src/Storage/Operators/Update/Utils.ts
Normal file
168
src/Storage/Operators/Update/Utils.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import * as dot from "dot-prop";
|
||||
import { deepEqual } from "fast-equals";
|
||||
import { Query } from "mingo";
|
||||
|
||||
import { clone } from "../../../Clone.js";
|
||||
import { Document, Filter, WithId } from "../../../Types.js";
|
||||
|
||||
type UpdateValue = (data: any, key: string, target: string) => any;
|
||||
|
||||
export function setPositionalData<TSchema extends Document = Document>(
|
||||
document: WithId<TSchema>,
|
||||
criteria: Filter<WithId<TSchema>>,
|
||||
key: string,
|
||||
update: {
|
||||
object: UpdateValue;
|
||||
value: UpdateValue;
|
||||
}
|
||||
): boolean {
|
||||
const { filter, path, target } = getPositionalFilter(criteria, key);
|
||||
|
||||
const values = getPropertyValues(document, path);
|
||||
const items =
|
||||
typeof filter === "object"
|
||||
? getPositionalUpdateQuery(clone(values), key, filter, target, update.object)
|
||||
: getPositionalUpdate(clone(values), key, filter, target, update.value);
|
||||
|
||||
dot.setProperty(document, path, items);
|
||||
|
||||
return deepEqual(values, items) === false;
|
||||
}
|
||||
|
||||
function getPropertyValues(document: Document, path: string): string[] {
|
||||
const values = dot.getProperty(document, path);
|
||||
if (values === undefined) {
|
||||
throw new Error("Values is undefined");
|
||||
}
|
||||
if (Array.isArray(values) === false) {
|
||||
throw new Error("Values is not an array");
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function getPositionalUpdate(
|
||||
items: any[],
|
||||
key: string,
|
||||
filter: string,
|
||||
target: string,
|
||||
updateValue: UpdateValue
|
||||
): any[] {
|
||||
let index = 0;
|
||||
for (const item of items) {
|
||||
if (item === filter) {
|
||||
items[index] = updateValue(items[index], key, target);
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function getPositionalUpdateQuery(
|
||||
items: any[],
|
||||
key: string,
|
||||
filter: Filter<any>,
|
||||
target: string,
|
||||
updateValue: UpdateValue
|
||||
): any[] {
|
||||
let index = 0;
|
||||
for (const item of items) {
|
||||
if (new Query(filter).test(item) === true) {
|
||||
if (target === "") {
|
||||
items[index] = updateValue(items[index], key, target);
|
||||
} else {
|
||||
dot.setProperty(item, target, updateValue(items[index], key, target));
|
||||
}
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function getPositionalFilter(criteria: Filter<any>, key: string): PositionalFilter {
|
||||
const [leftPath, rightPath] = key.split("$");
|
||||
|
||||
const lKey = trimSeparators(leftPath);
|
||||
const rKey = trimSeparators(rightPath);
|
||||
|
||||
for (const key in criteria) {
|
||||
const result = getPositionalCriteriaFilter(key, lKey, rKey, criteria);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
filter: criteria[lKey],
|
||||
path: lKey,
|
||||
target: rKey
|
||||
};
|
||||
}
|
||||
|
||||
function getPositionalCriteriaFilter(
|
||||
key: string,
|
||||
lKey: string,
|
||||
rKey: string,
|
||||
criteria: Filter<any>
|
||||
): PositionalFilter | undefined {
|
||||
if (key.includes(lKey) === true) {
|
||||
const isObject = typeof criteria[key] === "object";
|
||||
if (key.includes(".") === true || isObject === true) {
|
||||
return {
|
||||
filter:
|
||||
trimSeparators(key.replace(lKey, "")) === ""
|
||||
? (criteria[key] as any).$elemMatch !== undefined
|
||||
? (criteria[key] as any).$elemMatch
|
||||
: criteria[key]
|
||||
: {
|
||||
[trimSeparators(key.replace(lKey, ""))]: criteria[key]
|
||||
},
|
||||
path: lKey,
|
||||
target: rKey
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function trimSeparators(value: string): string {
|
||||
return value.replace(/^\.+|\.+$/gm, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* A position filter is used to find documents to update in an array of values.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* const document = {
|
||||
* grades: [
|
||||
* { grade: 80, mean: 75, std: 8 },
|
||||
* { grade: 85, mean: 90, std: 5 },
|
||||
* { grade: 85, mean: 85, std: 8 }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* updateOne({ "grades.grade": 85 }, { $set: { "grades.$.std": 6 } } })
|
||||
* ```
|
||||
*
|
||||
* In the above example the filter would be `{ grade: 85 }` which is used to find
|
||||
* objects to update in an array of values.
|
||||
*/
|
||||
type PositionalFilter = {
|
||||
/**
|
||||
* The filter to use to find the values to update in an array.
|
||||
*/
|
||||
filter: any;
|
||||
|
||||
/**
|
||||
* The path to the array of values of the parent document. Eg. `grades`.
|
||||
*/
|
||||
path: string;
|
||||
|
||||
/**
|
||||
* The path to the key to update in the array of values. Eg. `std`.
|
||||
*/
|
||||
target: string;
|
||||
};
|
||||
2
src/Storage/Operators/Update/mod.ts
Normal file
2
src/Storage/Operators/Update/mod.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./Result.js";
|
||||
export * from "./Update.js";
|
||||
177
src/Storage/Storage.ts
Normal file
177
src/Storage/Storage.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Cursor } from "mingo/cursor";
|
||||
import { nanoid } from "nanoid";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import { BroadcastChannel, StorageBroadcast } from "../Broadcast.js";
|
||||
import { Document, Filter, UpdateFilter, WithId } from "../Types.js";
|
||||
import { InsertManyResult, InsertOneResult } from "./Operators/Insert/mod.js";
|
||||
import { RemoveResult } from "./Operators/Remove/mod.js";
|
||||
import { UpdateResult } from "./Operators/Update/mod.js";
|
||||
|
||||
export abstract class Storage<TSchema extends Document = Document> {
|
||||
readonly observable = {
|
||||
change: new Subject<ChangeEvent<TSchema>>(),
|
||||
flush: new Subject<void>()
|
||||
};
|
||||
|
||||
status: Status = "loading";
|
||||
|
||||
readonly #channel: BroadcastChannel;
|
||||
|
||||
constructor(readonly name: string, readonly id = nanoid()) {
|
||||
this.#channel = new BroadcastChannel(`valkyr:db:${name}`);
|
||||
this.#channel.onmessage = ({ data }: MessageEvent<StorageBroadcast<TSchema>>) => {
|
||||
if (data.name !== this.name) {
|
||||
return;
|
||||
}
|
||||
switch (data.type) {
|
||||
case "flush": {
|
||||
this.observable.flush.next();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
this.observable.change.next(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Resolver
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
abstract resolve(): Promise<this>;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Status
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
is(status: Status): boolean {
|
||||
return this.status === status;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Broadcaster
|
||||
|--------------------------------------------------------------------------------
|
||||
|
|
||||
| Broadcast local changes with any change listeners in the current and other
|
||||
| browser tabs and window.
|
||||
|
|
||||
*/
|
||||
|
||||
broadcast(type: StorageBroadcast<TSchema>["type"], data?: TSchema | TSchema[]): void {
|
||||
switch (type) {
|
||||
case "flush": {
|
||||
this.observable.flush.next();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
this.observable.change.next({ type, data: data as any });
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.#channel.postMessage({ name: this.name, type, data });
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Operations
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
abstract has(id: string): Promise<boolean>;
|
||||
|
||||
abstract insertOne(document: Partial<WithId<TSchema>>): Promise<InsertOneResult>;
|
||||
|
||||
abstract insertMany(documents: Partial<WithId<TSchema>>[]): Promise<InsertManyResult>;
|
||||
|
||||
abstract findById(id: string): Promise<WithId<TSchema> | undefined>;
|
||||
|
||||
abstract find(filter?: Filter<WithId<TSchema>>, options?: Options): Promise<WithId<TSchema>[]>;
|
||||
|
||||
abstract updateOne(filter: Filter<WithId<TSchema>>, operators: UpdateFilter<TSchema>): Promise<UpdateResult>;
|
||||
|
||||
abstract updateMany(filter: Filter<WithId<TSchema>>, operators: UpdateFilter<TSchema>): Promise<UpdateResult>;
|
||||
|
||||
abstract replace(filter: Filter<WithId<TSchema>>, document: TSchema): Promise<UpdateResult>;
|
||||
|
||||
abstract remove(filter: Filter<WithId<TSchema>>): Promise<RemoveResult>;
|
||||
|
||||
abstract count(filter?: Filter<WithId<TSchema>>): Promise<number>;
|
||||
|
||||
abstract flush(): Promise<void>;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Destructor
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
destroy() {
|
||||
this.#channel.close();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utilities
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export function addOptions(cursor: Cursor, options: Options): Cursor {
|
||||
if (options.sort) {
|
||||
cursor.sort(options.sort);
|
||||
}
|
||||
if (options.skip !== undefined) {
|
||||
cursor.skip(options.skip);
|
||||
}
|
||||
if (options.limit !== undefined) {
|
||||
cursor.limit(options.limit);
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type Status = "loading" | "ready";
|
||||
|
||||
export type ChangeEvent<TSchema extends Document = Document> =
|
||||
| {
|
||||
type: "insertOne" | "updateOne";
|
||||
data: WithId<TSchema>;
|
||||
}
|
||||
| {
|
||||
type: "insertMany" | "updateMany" | "remove";
|
||||
data: WithId<TSchema>[];
|
||||
};
|
||||
|
||||
export type Options = {
|
||||
sort?: {
|
||||
[key: string]: 1 | -1;
|
||||
};
|
||||
skip?: number;
|
||||
range?: {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
offset?: {
|
||||
value: string;
|
||||
direction: 1 | -1;
|
||||
};
|
||||
limit?: number;
|
||||
index?: Index;
|
||||
};
|
||||
|
||||
export type Index = {
|
||||
[index: string]: any;
|
||||
};
|
||||
5
src/Storage/mod.ts
Normal file
5
src/Storage/mod.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./Errors.js";
|
||||
export * from "./Operators/Insert/mod.js";
|
||||
export * from "./Operators/Remove/mod.js";
|
||||
export * from "./Operators/Update/mod.js";
|
||||
export * from "./Storage.js";
|
||||
174
src/Types.ts
Normal file
174
src/Types.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { BSONRegExp, BSONType } from "bson";
|
||||
|
||||
export type Document = {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type WithId<TSchema> = {
|
||||
id: string;
|
||||
} & TSchema;
|
||||
|
||||
export type Filter<TSchema> = {
|
||||
[P in keyof TSchema]?: Condition<TSchema[P]>;
|
||||
} & RootFilterOperators<TSchema> &
|
||||
Record<string, any>;
|
||||
|
||||
export type UpdateFilter<TSchema> = {
|
||||
$inc?: OnlyFieldsOfType<TSchema, number>;
|
||||
$set?: MatchKeysAndValues<TSchema> | MatchKeysToFunctionValues<TSchema> | Record<string, any>;
|
||||
$unset?: OnlyFieldsOfType<TSchema, any, "" | true | 1>;
|
||||
$pull?: PullOperator<TSchema>;
|
||||
$push?: PushOperator<TSchema>;
|
||||
};
|
||||
|
||||
type RootFilterOperators<TSchema> = {
|
||||
$and?: Filter<TSchema>[];
|
||||
$nor?: Filter<TSchema>[];
|
||||
$or?: Filter<TSchema>[];
|
||||
$text?: {
|
||||
$search: string;
|
||||
$language?: string;
|
||||
$caseSensitive?: boolean;
|
||||
$diacriticSensitive?: boolean;
|
||||
};
|
||||
$where?: string | ((this: TSchema) => boolean);
|
||||
$comment?: string | Document;
|
||||
};
|
||||
|
||||
type Condition<T> = AlternativeType<T> | FilterOperators<AlternativeType<T>>;
|
||||
|
||||
type AlternativeType<T> = T extends ReadonlyArray<infer U> ? T | RegExpOrString<U> : RegExpOrString<T>;
|
||||
|
||||
type RegExpOrString<T> = T extends string ? BSONRegExp | RegExp | T : T;
|
||||
|
||||
type FilterOperators<TValue> = {
|
||||
$eq?: TValue;
|
||||
$gt?: TValue;
|
||||
$gte?: TValue;
|
||||
$in?: ReadonlyArray<TValue>;
|
||||
$lt?: TValue;
|
||||
$lte?: TValue;
|
||||
$ne?: TValue;
|
||||
$nin?: ReadonlyArray<TValue>;
|
||||
$not?: TValue extends string ? FilterOperators<TValue> | RegExp : FilterOperators<TValue>;
|
||||
/**
|
||||
* When `true`, `$exists` matches the documents that contain the field,
|
||||
* including documents where the field value is null.
|
||||
*/
|
||||
$exists?: boolean;
|
||||
$type?: BSONType | BSONTypeAlias;
|
||||
$expr?: Record<string, any>;
|
||||
$jsonSchema?: Record<string, any>;
|
||||
$mod?: TValue extends number ? [number, number] : never;
|
||||
$regex?: TValue extends string ? RegExp | string : never;
|
||||
$options?: TValue extends string ? string : never;
|
||||
$geoIntersects?: {
|
||||
$geometry: Document;
|
||||
};
|
||||
$geoWithin?: Document;
|
||||
$near?: Document;
|
||||
$nearSphere?: Document;
|
||||
$maxDistance?: number;
|
||||
$all?: ReadonlyArray<any>;
|
||||
$elemMatch?: Document;
|
||||
$size?: TValue extends ReadonlyArray<any> ? number : never;
|
||||
$bitsAllClear?: BitwiseFilter;
|
||||
$bitsAllSet?: BitwiseFilter;
|
||||
$bitsAnyClear?: BitwiseFilter;
|
||||
$bitsAnySet?: BitwiseFilter;
|
||||
$rand?: Record<string, never>;
|
||||
};
|
||||
|
||||
type BSONTypeAlias = keyof typeof BSONType;
|
||||
|
||||
type BitwiseFilter = number | ReadonlyArray<number>;
|
||||
|
||||
type OnlyFieldsOfType<TSchema, FieldType = any, AssignableType = FieldType> = IsAny<
|
||||
TSchema[keyof TSchema],
|
||||
Record<string, FieldType>,
|
||||
AcceptedFields<TSchema, FieldType, AssignableType> &
|
||||
NotAcceptedFields<TSchema, FieldType> &
|
||||
Record<string, AssignableType>
|
||||
>;
|
||||
|
||||
type MatchKeysAndValues<TSchema> = Readonly<Partial<TSchema>>;
|
||||
|
||||
type MatchKeysToFunctionValues<TSchema> = {
|
||||
readonly [key in keyof TSchema]?: (this: TSchema, value: TSchema[key]) => TSchema[key];
|
||||
};
|
||||
|
||||
type PullOperator<TSchema> = ({
|
||||
readonly [key in KeysOfAType<TSchema, ReadonlyArray<any>>]?:
|
||||
| Partial<Flatten<TSchema[key]>>
|
||||
| FilterOperations<Flatten<TSchema[key]>>;
|
||||
} & NotAcceptedFields<TSchema, ReadonlyArray<any>>) & {
|
||||
readonly [key: string]: FilterOperators<any> | any;
|
||||
};
|
||||
|
||||
type PushOperator<TSchema> = ({
|
||||
readonly [key in KeysOfAType<TSchema, ReadonlyArray<any>>]?:
|
||||
| Flatten<TSchema[key]>
|
||||
| ArrayOperator<Array<Flatten<TSchema[key]>>>;
|
||||
} & NotAcceptedFields<TSchema, ReadonlyArray<any>>) & {
|
||||
readonly [key: string]: ArrayOperator<any> | any;
|
||||
};
|
||||
|
||||
type KeysOfAType<TSchema, Type> = {
|
||||
[key in keyof TSchema]: NonNullable<TSchema[key]> extends Type ? key : never;
|
||||
}[keyof TSchema];
|
||||
|
||||
type AcceptedFields<TSchema, FieldType, AssignableType> = {
|
||||
readonly [key in KeysOfAType<TSchema, FieldType>]?: AssignableType;
|
||||
};
|
||||
|
||||
type NotAcceptedFields<TSchema, FieldType> = {
|
||||
readonly [key in KeysOfOtherType<TSchema, FieldType>]?: never;
|
||||
};
|
||||
|
||||
type Flatten<Type> = Type extends ReadonlyArray<infer Item> ? Item : Type;
|
||||
|
||||
type IsAny<Type, ResultIfAny, ResultIfNotAny> = true extends false & Type ? ResultIfAny : ResultIfNotAny;
|
||||
|
||||
type FilterOperations<T> = T extends Record<string, any>
|
||||
? {
|
||||
[key in keyof T]?: FilterOperators<T[key]>;
|
||||
}
|
||||
: FilterOperators<T>;
|
||||
|
||||
type ArrayOperator<Type> = {
|
||||
$each?: Array<Flatten<Type>>;
|
||||
$slice?: number;
|
||||
$position?: number;
|
||||
$sort?: Sort;
|
||||
};
|
||||
|
||||
type Sort =
|
||||
| string
|
||||
| Exclude<
|
||||
SortDirection,
|
||||
{
|
||||
$meta: string;
|
||||
}
|
||||
>
|
||||
| string[]
|
||||
| {
|
||||
[key: string]: SortDirection;
|
||||
}
|
||||
| Map<string, SortDirection>
|
||||
| [string, SortDirection][]
|
||||
| [string, SortDirection];
|
||||
|
||||
type SortDirection =
|
||||
| 1
|
||||
| -1
|
||||
| "asc"
|
||||
| "desc"
|
||||
| "ascending"
|
||||
| "descending"
|
||||
| {
|
||||
$meta: string;
|
||||
};
|
||||
|
||||
type KeysOfOtherType<TSchema, Type> = {
|
||||
[key in keyof TSchema]: NonNullable<TSchema[key]> extends Type ? never : key;
|
||||
}[keyof TSchema];
|
||||
4
src/index.ts
Normal file
4
src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./Collection.js";
|
||||
export * from "./Databases/mod.js";
|
||||
export * from "./Storage/mod.js";
|
||||
export type { Document, Filter } from "./Types.js";
|
||||
49
tests/Cache.Test.ts
Normal file
49
tests/Cache.Test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { IndexedDbCache } from "../src/Databases/IndexedDb.Cache.js";
|
||||
import { Options } from "../src/index.js";
|
||||
import { WithId } from "../src/Types.js";
|
||||
|
||||
describe("IndexedDbCache", () => {
|
||||
let cache: IndexedDbCache;
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new IndexedDbCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cache.flush();
|
||||
});
|
||||
|
||||
const sampleDocuments: WithId<{ name: string }>[] = [
|
||||
{ id: "doc1", name: "Document 1" },
|
||||
{ id: "doc2", name: "Document 2" }
|
||||
];
|
||||
|
||||
const sampleCriteria = { name: { $eq: "Document 1" } };
|
||||
const sampleOptions: Options = { sort: { name: 1 } };
|
||||
|
||||
test("hash", () => {
|
||||
const hashCode = cache.hash(sampleCriteria, sampleOptions);
|
||||
expect(typeof hashCode).toBe("number");
|
||||
});
|
||||
|
||||
test("set and get", () => {
|
||||
const hashCode = cache.hash(sampleCriteria, sampleOptions);
|
||||
cache.set(hashCode, sampleDocuments);
|
||||
const result = cache.get(hashCode);
|
||||
expect(result).toEqual(sampleDocuments);
|
||||
});
|
||||
|
||||
test("get undefined", () => {
|
||||
const hashCode = cache.hash(sampleCriteria, sampleOptions);
|
||||
const result = cache.get(hashCode);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("flush", () => {
|
||||
const hashCode = cache.hash(sampleCriteria, sampleOptions);
|
||||
cache.set(hashCode, sampleDocuments);
|
||||
cache.flush();
|
||||
const result = cache.get(hashCode);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
77
tests/Collection.Test.ts
Normal file
77
tests/Collection.Test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Collection } from "../src/Collection.js";
|
||||
import { MemoryStorage } from "../src/Databases/MemoryDb.Storage.js";
|
||||
import { getUsers, UserDocument } from "./Users.Mock.js";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Unit Tests
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
describe("Collection", () => {
|
||||
it("should successfully create a new collection", () => {
|
||||
const collection = new Collection<UserDocument>("users", new MemoryStorage("users"));
|
||||
expect(collection.name).toEqual("users");
|
||||
collection.storage.destroy();
|
||||
});
|
||||
|
||||
describe("when finding document by id", () => {
|
||||
it("should return model instance if document exists", async () => {
|
||||
const collection = new Collection<UserDocument>("users", new MemoryStorage("users"));
|
||||
const users = getUsers();
|
||||
await collection.insertMany(users);
|
||||
expect(await collection.findById(users[0].id)).toEqual(users[0]);
|
||||
collection.storage.destroy();
|
||||
});
|
||||
|
||||
it("should return undefined if document does not exists", async () => {
|
||||
const collection = new Collection<UserDocument>("users", new MemoryStorage("users"));
|
||||
expect(await collection.findById("user-4")).toBeUndefined();
|
||||
collection.storage.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when finding document by filter", () => {
|
||||
it("should return model instances when matches are found", async () => {
|
||||
const collection = new Collection<UserDocument>("users", new MemoryStorage("users"));
|
||||
const users = getUsers();
|
||||
await collection.insertMany(users);
|
||||
expect(await collection.find({ name: "Jane Doe" })).toEqual([users[1]]);
|
||||
collection.storage.destroy();
|
||||
});
|
||||
|
||||
it("should return empty array when no matches are found", async () => {
|
||||
const collection = new Collection<UserDocument>("users", new MemoryStorage("users"));
|
||||
expect(await collection.find({ name: "Rick Doe" })).toEqual([]);
|
||||
collection.storage.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when finding single document by filter", () => {
|
||||
it("should return model instance if document exists", async () => {
|
||||
const collection = new Collection<UserDocument>("users", new MemoryStorage("users"));
|
||||
const users = getUsers();
|
||||
await collection.insertMany(users);
|
||||
expect(await collection.findOne({ name: "Jane Doe" })).toEqual(users[1]);
|
||||
collection.storage.destroy();
|
||||
});
|
||||
|
||||
it("should return undefined if document does not exists", async () => {
|
||||
const collection = new Collection<UserDocument>("users", new MemoryStorage("users"));
|
||||
expect(await collection.findOne({ name: "Rick Doe" })).toBeUndefined();
|
||||
collection.storage.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("should count documents by filter", () => {
|
||||
it("should return correct filter count", async () => {
|
||||
const collection = new Collection<UserDocument>("users", new MemoryStorage("users"));
|
||||
const users = getUsers();
|
||||
await collection.insertMany(users);
|
||||
expect(await collection.count({ name: "Rick Doe" })).toEqual(0);
|
||||
expect(await collection.count({ name: "Jane Doe" })).toEqual(1);
|
||||
expect(await collection.count()).toEqual(2);
|
||||
collection.storage.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
12
tests/Hash.Test.ts
Normal file
12
tests/Hash.Test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { hashCodeQuery } from "../src/Hash.js";
|
||||
import { Options } from "../src/index.js";
|
||||
|
||||
describe("hashCodeQuery", () => {
|
||||
const filter = { name: { $eq: "Document 1" } };
|
||||
const options: Options = { sort: { name: 1 } };
|
||||
|
||||
test("return correct hash code", () => {
|
||||
const hashCode = hashCodeQuery(filter, options);
|
||||
expect(typeof hashCode).toBe("number");
|
||||
});
|
||||
});
|
||||
33
tests/Insert.Test.ts
Normal file
33
tests/Insert.Test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Collection } from "../src/Collection.js";
|
||||
import { MemoryStorage } from "../src/Databases/MemoryDb.Storage.js";
|
||||
import { DuplicateDocumentError } from "../src/index.js";
|
||||
import { getUsers } from "./Users.Mock.js";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Unit Tests
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
describe("Storage Insert", () => {
|
||||
it("should successfully insert a new document", async () => {
|
||||
const collection = new Collection("users", new MemoryStorage("users"));
|
||||
const users = getUsers();
|
||||
await collection.insertMany(users);
|
||||
expect(await collection.storage.findById(users[0].id)).toEqual(users[0]);
|
||||
expect(await collection.storage.findById(users[1].id)).toEqual(users[1]);
|
||||
collection.storage.destroy();
|
||||
});
|
||||
|
||||
it("should throw an error if the document already exists", async () => {
|
||||
const collection = new Collection("users", new MemoryStorage("users"));
|
||||
const users = getUsers();
|
||||
try {
|
||||
await collection.insertOne(users[0]);
|
||||
} catch (err) {
|
||||
expect(err instanceof DuplicateDocumentError).toEqual(true);
|
||||
expect(err).toEqual(new DuplicateDocumentError(users[0], collection.storage));
|
||||
}
|
||||
collection.storage.destroy();
|
||||
});
|
||||
});
|
||||
20
tests/Remove.Test.ts
Normal file
20
tests/Remove.Test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Collection } from "../src/Collection.js";
|
||||
import { MemoryStorage } from "../src/Databases/MemoryDb.Storage.js";
|
||||
import { RemoveResult } from "../src/index.js";
|
||||
import { getUsers } from "./Users.Mock.js";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Unit Tests
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
describe("Storage Remove", () => {
|
||||
it("should successfully delete document", async () => {
|
||||
const collection = new Collection("users", new MemoryStorage("users"));
|
||||
const users = getUsers();
|
||||
await collection.insertMany(users);
|
||||
expect(await collection.remove({ id: "user-1" })).toEqual(new RemoveResult(1));
|
||||
collection.storage.destroy();
|
||||
});
|
||||
});
|
||||
1369
tests/Update.Test.ts
Normal file
1369
tests/Update.Test.ts
Normal file
File diff suppressed because it is too large
Load Diff
45
tests/Users.Mock.ts
Normal file
45
tests/Users.Mock.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { clone } from "../src/Clone.js";
|
||||
import { WithId } from "../src/Types.js";
|
||||
|
||||
const users: WithId<UserDocument>[] = [
|
||||
{
|
||||
id: "user-1",
|
||||
name: "John Doe",
|
||||
email: "john.doe@test.none",
|
||||
friends: [
|
||||
{
|
||||
id: "user-2",
|
||||
alias: "Jane"
|
||||
}
|
||||
],
|
||||
interests: ["movies", "tv", "sports"]
|
||||
},
|
||||
{
|
||||
id: "user-2",
|
||||
name: "Jane Doe",
|
||||
email: "jane.doe@test.none",
|
||||
friends: [
|
||||
{
|
||||
id: "user-1",
|
||||
alias: "John"
|
||||
}
|
||||
],
|
||||
interests: ["movies", "fitness", "dance"]
|
||||
}
|
||||
];
|
||||
|
||||
export function getUsers(): WithId<UserDocument>[] {
|
||||
return clone(users);
|
||||
}
|
||||
|
||||
export type UserDocument = {
|
||||
name: string;
|
||||
email: string;
|
||||
friends: Friend[];
|
||||
interests: string[];
|
||||
};
|
||||
|
||||
type Friend = {
|
||||
id: string;
|
||||
alias: string;
|
||||
};
|
||||
8
tsconfig.build.json
Normal file
8
tsconfig.build.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"exclude": ["./tests"]
|
||||
}
|
||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "Default",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
|
||||
"lib": ["ES2022", "dom", "dom.iterable"],
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"useUnknownInCatchVariables": false,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["./src", "./tests"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user