refactor: simplify and add memory indexing

This commit is contained in:
2026-01-05 04:42:46 +01:00
parent 74d45cbe92
commit ed15a0eb27
37 changed files with 854 additions and 1328 deletions

View File

@@ -24,7 +24,7 @@
},
"test": {
"command": "deno test --allow-all",
"command": "deno test --allow-all ./src",
"description": "Run all tests using Denos built-in test runner."
},

63
deno.lock generated
View File

@@ -1,21 +1,17 @@
{
"version": "5",
"specifiers": {
"npm:@biomejs/biome@*": "2.3.10",
"npm:@biomejs/biome@2.3.10": "2.3.10",
"npm:@jsr/std__assert@1": "1.0.16",
"npm:@jsr/std__async@1": "1.0.16",
"npm:@jsr/std__testing@1": "1.0.16",
"npm:@jsr/valkyr__event-emitter@1.0.1": "1.0.1",
"npm:@jsr/valkyr__testcontainers@2": "2.0.2",
"npm:bson@7.0.0": "7.0.0",
"npm:dot-prop@10.1.0": "10.1.0",
"npm:expect@30.2.0": "30.2.0",
"npm:fake-indexeddb@6.2.5": "6.2.5",
"npm:fast-equals@6.0.0": "6.0.0",
"npm:idb@8.0.3": "8.0.3",
"npm:mingo@7.1.1": "7.1.1",
"npm:rfdc@1.4.1": "1.4.1",
"npm:rxjs@7.8.2": "7.8.2",
"npm:sorted-btree@2.1.0": "2.1.0",
"npm:zod@4.3.4": "4.3.4"
},
"npm": {
@@ -174,6 +170,13 @@
],
"tarball": "https://npm.jsr.io/~/11/@jsr/std__testing/1.0.16.tgz"
},
"@jsr/valkyr__event-emitter@1.0.1": {
"integrity": "sha512-mre5tWJddz8LylSQWuLOw3zgIxd2JmhGRV46jKXNPCGzY2NKJwGGT9H7SBw36RV4dW7jnnH2U1aCJkh8IS/pzA==",
"dependencies": [
"eventemitter3"
],
"tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__event-emitter/1.0.1.tgz"
},
"@jsr/valkyr__testcontainers@2.0.2": {
"integrity": "sha512-YnmfraYFr3msoUGrIFeElm03nbQqXOaPu0QUT6JI3w6/mIYpVfzPxghkB7gn2RIc81QgrqjwKJE/AL3dltlR1w==",
"dependencies": [
@@ -254,9 +257,6 @@
"bson@6.10.4": {
"integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng=="
},
"bson@7.0.0": {
"integrity": "sha512-Kwc6Wh4lQ5OmkqqKhYGKIuELXl+EPYSCObVE6bWsp1T/cGkOCBN0I8wF/T44BiuhHyNi1mmKVPXk60d41xZ7kw=="
},
"chalk@4.1.2": {
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dependencies": [
@@ -276,15 +276,12 @@
"color-name@1.1.4": {
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"dot-prop@10.1.0": {
"integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==",
"dependencies": [
"type-fest"
]
},
"escape-string-regexp@2.0.0": {
"integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="
},
"eventemitter3@5.0.1": {
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
},
"expect@30.2.0": {
"integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==",
"dependencies": [
@@ -299,9 +296,6 @@
"fake-indexeddb@6.2.5": {
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="
},
"fast-equals@6.0.0": {
"integrity": "sha512-PFhhIGgdM79r5Uztdj9Zb6Tt1zKafqVfdMGwVca1z5z6fbX7DmsySSuJd8HiP6I1j505DCS83cLxo5rmSNeVEA=="
},
"fill-range@7.1.1": {
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dependencies": [
@@ -401,7 +395,7 @@
"integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==",
"dependencies": [
"@mongodb-js/saslprep",
"bson@6.10.4",
"bson",
"mongodb-connection-string-url"
]
},
@@ -431,18 +425,12 @@
"react-is@18.3.1": {
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
},
"rfdc@1.4.1": {
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="
},
"rxjs@7.8.2": {
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dependencies": [
"tslib"
]
},
"slash@3.0.0": {
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="
},
"sorted-btree@2.1.0": {
"integrity": "sha512-AtYXy3lL+5jrATpbymC2bM8anN/3maLkmVCd94MzypnKjokfCid/zeS3rvXedv7W6ffSfqKIGdz3UaJPWRBZ0g=="
},
"sparse-bitfield@3.0.3": {
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"dependencies": [
@@ -461,9 +449,6 @@
"has-flag"
]
},
"tagged-tag@1.0.0": {
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="
},
"to-regex-range@5.0.1": {
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dependencies": [
@@ -476,15 +461,6 @@
"punycode"
]
},
"tslib@2.8.1": {
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"type-fest@5.3.1": {
"integrity": "sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg==",
"dependencies": [
"tagged-tag"
]
},
"undici-types@7.10.0": {
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="
},
@@ -509,16 +485,13 @@
"npm:@jsr/std__assert@1",
"npm:@jsr/std__async@1",
"npm:@jsr/std__testing@1",
"npm:@jsr/valkyr__event-emitter@1.0.1",
"npm:@jsr/valkyr__testcontainers@2",
"npm:bson@7.0.0",
"npm:dot-prop@10.1.0",
"npm:expect@30.2.0",
"npm:fake-indexeddb@6.2.5",
"npm:fast-equals@6.0.0",
"npm:idb@8.0.3",
"npm:mingo@7.1.1",
"npm:rfdc@1.4.1",
"npm:rxjs@7.8.2",
"npm:sorted-btree@2.1.0",
"npm:zod@4.3.4"
]
}

View File

@@ -1,12 +1,8 @@
{
"dependencies": {
"bson": "7.0.0",
"dot-prop": "10.1.0",
"fast-equals": "6.0.0",
"@valkyr/event-emitter": "npm:@jsr/valkyr__event-emitter@1.0.1",
"idb": "8.0.3",
"mingo": "7.1.1",
"rfdc": "1.4.1",
"rxjs": "7.8.2",
"zod": "4.3.4"
},
"devDependencies": {

View File

@@ -1,4 +1,4 @@
import type { AnyObject } from "mingo/types";
import type { AnyDocument } from "./types.ts";
export const BroadcastChannel =
globalThis.BroadcastChannel ??
@@ -11,13 +11,8 @@ export const BroadcastChannel =
export type StorageBroadcast =
| {
name: string;
type: "insertOne" | "updateOne";
data: AnyObject;
}
| {
name: string;
type: "insertMany" | "updateMany" | "remove";
data: AnyObject[];
type: "insert" | "update" | "remove";
data: AnyDocument[];
}
| {
name: string;

View File

@@ -1,3 +0,0 @@
import makeClone from "rfdc";
export const clone = makeClone();

View File

@@ -1,11 +1,12 @@
import type { Subscription } from "@valkyr/event-emitter";
import type { AnyObject, Criteria } from "mingo/types";
import type { Modifier } from "mingo/updater";
import { Observable, type Subject, type Subscription } from "rxjs";
import type z from "zod";
import type { ZodObject, ZodRawShape } from "zod";
import z from "zod";
import { observe, observeOne } from "./observe/mod.ts";
import type { ChangeEvent, InsertResult, QueryOptions, Storage, UpdateResult } from "./storage/mod.ts";
import type { Index } from "./registrars.ts";
import type { ChangeEvent, QueryOptions, Storage, UpdateResult } from "./storage.ts";
import type { AnyDocument } from "./types.ts";
/*
@@ -16,23 +17,54 @@ import type { AnyDocument } from "./types.ts";
export class Collection<
TOptions extends AnyCollectionOptions = AnyCollectionOptions,
TAdapter extends Storage = TOptions["adapter"],
TPrimaryKey extends string = TOptions["primaryKey"],
TStorage extends Storage = TOptions["storage"],
TSchema extends AnyDocument = z.output<ZodObject<TOptions["schema"]>>,
> {
declare readonly $schema: TSchema;
constructor(readonly options: TOptions) {}
readonly #schema: ZodObject<TOptions["schema"]>;
readonly #pkey: string | number;
get observable(): {
change: Subject<ChangeEvent>;
flush: Subject<void>;
} {
return this.storage.observable;
constructor(readonly options: TOptions) {
this.#schema = z.strictObject(options.schema);
this.#pkey = this.primaryKey;
}
get storage(): TAdapter {
return this.options.adapter;
get name(): string {
return this.options.name;
}
get storage(): TStorage {
return this.options.storage;
}
get schema(): TOptions["schema"] {
return this.options.schema;
}
get primaryKey(): string {
for (const index of this.options.indexes ?? []) {
if (index[1]?.primary === true) {
return index[0] as string;
}
}
throw new Error(`Collection '${this.name}' is missing required primary key assignment.`);
}
/*
|--------------------------------------------------------------------------------
| Utilities
|--------------------------------------------------------------------------------
*/
getPrimaryKeyValue(document: AnyDocument): string | number {
const id = document[this.#pkey];
if (id === undefined || typeof id !== "string") {
throw new Error(
`Primary Key: Missing primary key '${this.#pkey}' on given document: ${JSON.stringify(document, null, 2)}`,
);
}
return id;
}
/*
@@ -41,73 +73,20 @@ export class Collection<
|--------------------------------------------------------------------------------
*/
async insertOne(values: TSchema | Omit<TSchema, TPrimaryKey>): Promise<InsertResult> {
return this.storage.resolve().then((storage) =>
storage.insertOne({
collection: this.options.name,
pkey: this.options.primaryKey,
values,
}),
);
async insert(documents: TSchema[]): Promise<void> {
return this.storage.resolve().then((storage) => storage.insert(documents));
}
async insertMany(values: (TSchema | Omit<TSchema, TPrimaryKey>)[]): Promise<InsertResult> {
return this.storage.resolve().then((storage) =>
storage.insertMany({
collection: this.options.name,
pkey: this.options.primaryKey,
values,
}),
);
}
async updateOne(
async update(
condition: Criteria<TSchema>,
modifier: Modifier<TSchema>,
arrayFilters?: AnyObject[],
): Promise<UpdateResult> {
return this.storage.resolve().then((storage) =>
storage.updateOne({
collection: this.options.name,
pkey: this.options.primaryKey,
condition,
modifier,
arrayFilters,
}),
);
}
async updateMany(
condition: Criteria<TSchema>,
modifier: Modifier<TSchema>,
arrayFilters?: AnyObject[],
): Promise<UpdateResult> {
return this.storage.resolve().then((storage) =>
storage.updateMany({
collection: this.options.name,
pkey: this.options.primaryKey,
condition,
modifier,
arrayFilters,
}),
);
}
async replaceOne(condition: Criteria<TSchema>, document: TSchema): Promise<UpdateResult> {
return this.storage.resolve().then((storage) =>
storage.replace({
collection: this.options.name,
pkey: this.options.primaryKey,
condition,
document,
}),
);
return this.storage.resolve().then((storage) => storage.update(condition, modifier, arrayFilters));
}
async remove(condition: Criteria<TSchema>): Promise<number> {
return this.storage
.resolve()
.then((storage) => storage.remove({ collection: this.options.name, pkey: this.options.primaryKey, condition }));
return this.storage.resolve().then((storage) => storage.remove(condition));
}
/*
@@ -128,28 +107,9 @@ export class Collection<
): Subscription;
subscribe(condition: Criteria<TSchema> = {}, options?: QueryOptions, next?: (...args: any[]) => void): Subscription {
if (options?.limit === 1) {
return this.#observeOne(condition).subscribe({ next });
return observeOne(this as any, condition, (values) => next?.(values as any));
}
return this.#observe(condition, options).subscribe({
next: (value: [TSchema[], TSchema[], ChangeEvent["type"]]) => next?.(...value),
});
}
#observe(
filter: Criteria<TSchema> = {},
options?: QueryOptions,
): Observable<[TSchema[], TSchema[], ChangeEvent["type"]]> {
return new Observable<[TSchema[], TSchema[], ChangeEvent["type"]]>((subscriber) => {
return observe(this as any, filter, options, (values, changed, type) =>
subscriber.next([values, changed, type] as any),
);
});
}
#observeOne(filter: Criteria<TSchema> = {}): Observable<TSchema | undefined> {
return new Observable<TSchema | undefined>((subscriber) => {
return observeOne(this as any, filter, (values) => subscriber.next(values as any));
});
return observe(this as any, condition, options, (values, changed, type) => next?.(values, changed, type));
}
/*
@@ -158,29 +118,26 @@ export class Collection<
|--------------------------------------------------------------------------------
*/
/**
* Retrieve a record by the document 'id' key.
*/
async findById(id: string): Promise<TSchema | undefined> {
return this.storage.resolve().then((storage) => storage.findById({ collection: this.options.name, id }));
}
/**
* Performs a mingo filter search over the collection data and returns
* a single document if one was found matching the filter and options.
*/
async findOne(condition: Criteria<TSchema> = {}, options?: QueryOptions): Promise<TSchema | undefined> {
return this.find(condition, options).then(([document]) => document);
async findOne(condition: Criteria<TSchema> = {}, options: QueryOptions = {}): Promise<TSchema | undefined> {
return this.findMany(condition, { ...options, limit: 1 }).then(([document]) => document);
}
/**
* Performs a mingo filter search over the collection data and returns any
* documents matching the provided filter and options.
*/
async find(condition: Criteria<TSchema> = {}, options?: QueryOptions): Promise<TSchema[]> {
async findMany(condition: Criteria<TSchema> = {}, options?: QueryOptions): Promise<TSchema[]> {
return this.storage
.resolve()
.then((storage) => storage.find({ collection: this.options.name, condition, options }));
.then((storage) =>
storage
.find(condition, options)
.then((documents) => documents.map((document) => this.#schema.parse(document) as TSchema)),
);
}
/**
@@ -200,6 +157,20 @@ export class Collection<
storage.flush();
});
}
/*
|--------------------------------------------------------------------------------
| Event Handlers
|--------------------------------------------------------------------------------
*/
onFlush(cb: () => void) {
return this.storage.event.subscribe("flush", cb);
}
onChange(cb: (event: ChangeEvent<TSchema>) => void) {
return this.storage.event.subscribe("change", cb);
}
}
/*
@@ -214,7 +185,6 @@ export type SubscriptionOptions = {
range?: QueryOptions["range"];
offset?: QueryOptions["offset"];
limit?: QueryOptions["limit"];
index?: QueryOptions["index"];
};
export type SubscribeToSingle = QueryOptions & {
@@ -225,16 +195,26 @@ export type SubscribeToMany = QueryOptions & {
limit?: number;
};
type AnyCollectionOptions = CollectionOptions<any, any, any, any>;
type AnyCollectionOptions = CollectionOptions<any, any, any>;
type CollectionOptions<
TName extends string,
TAdapter extends Storage,
TPrimaryKey extends string | number | symbol,
TSchema extends ZodRawShape,
> = {
type CollectionOptions<TName extends string, TStorage extends Storage, TSchema extends ZodRawShape> = {
/**
* Name of the collection.
*/
name: TName;
adapter: TAdapter;
primaryKey: TPrimaryKey;
/**
* Storage adapter used to persist the collection documents.
*/
storage: TStorage;
/**
* Schema definition of the document stored for the collection.
*/
schema: TSchema;
/**
* List of custom indexes for the collection.
*/
indexes: Index[];
};

View File

@@ -1,13 +1,15 @@
import { hashCodeQuery } from "../../hash.ts";
import type { QueryOptions } from "../../storage/mod.ts";
import type { Document, Filter } from "../../types.ts";
import type { Criteria } from "mingo/types";
export class IndexedDBCache<TSchema extends Document = Document> {
import { hashCodeQuery } from "../../hash.ts";
import type { QueryOptions } from "../../storage.ts";
import type { AnyDocument } from "../../types.ts";
export class IndexedDBCache<TSchema extends AnyDocument = AnyDocument> {
readonly #cache = new Map<number, string[]>();
readonly #documents = new Map<string, TSchema>();
hash(filter: Filter<TSchema>, options: QueryOptions): number {
return hashCodeQuery(filter, options);
hash(condition: Criteria<TSchema>, options: QueryOptions = {}): number {
return hashCodeQuery(condition, options);
}
set(hashCode: number, documents: TSchema[]) {
@@ -23,7 +25,7 @@ export class IndexedDBCache<TSchema extends Document = Document> {
get(hashCode: number): TSchema[] | undefined {
const ids = this.#cache.get(hashCode);
if (ids !== undefined) {
return ids.map((id) => this.#documents.get(id) as TSchema);
return ids.map((id) => this.#documents.get(id)).filter((document) => document !== undefined);
}
}

View File

@@ -2,30 +2,33 @@ import { type IDBPDatabase, openDB } from "idb";
import { Collection } from "../../collection.ts";
import type { DBLogger } from "../../logger.ts";
import type { Document } from "../../types.ts";
import type { Registrars } from "../registrars.ts";
import type { Index, Registrars } from "../../registrars.ts";
import { IndexedDBStorage } from "./storage.ts";
export class IndexedDB<TCollections extends StringRecord<Document>> {
readonly #collections = new Map<keyof TCollections, Collection<TCollections[keyof TCollections]>>();
export class IndexedDB<TOptions extends IndexedDBOptions> {
readonly #collections = new Map<string, Collection>();
readonly #db: Promise<IDBPDatabase<unknown>>;
constructor(readonly options: Options) {
constructor(readonly options: TOptions) {
this.#db = openDB(options.name, options.version ?? 1, {
upgrade: (db: IDBPDatabase) => {
for (const { name, primaryKey = "id", indexes = [] } of options.registrars) {
const store = db.createObjectStore(name as string, { keyPath: primaryKey });
store.createIndex(primaryKey, primaryKey, { unique: true });
for (const { name, indexes = [] } of options.registrars) {
const store = db.createObjectStore(name);
for (const [keyPath, options] of indexes) {
store.createIndex(keyPath, keyPath, options);
}
}
},
});
for (const { name, primaryKey = "id" } of options.registrars) {
for (const { name, schema, indexes } of options.registrars) {
this.#collections.set(
name,
new Collection(name, new IndexedDBStorage(name, primaryKey, this.#db, options.log ?? log)),
new Collection({
name,
storage: new IndexedDBStorage(name, indexes, this.#db, options.log),
schema,
indexes,
}),
);
}
}
@@ -36,7 +39,17 @@ export class IndexedDB<TCollections extends StringRecord<Document>> {
|--------------------------------------------------------------------------------
*/
collection<Name extends keyof TCollections = keyof TCollections>(name: Name) {
collection<
TName extends TOptions["registrars"][number]["name"],
TSchema = Extract<TOptions["registrars"][number], { name: TName }>["schema"],
>(
name: TName,
): Collection<{
name: TName;
storage: IndexedDBStorage;
schema: TSchema;
indexes: Index[];
}> {
const collection = this.#collections.get(name);
if (collection === undefined) {
throw new Error(`Collection '${name as string}' not found`);
@@ -65,13 +78,9 @@ export class IndexedDB<TCollections extends StringRecord<Document>> {
}
}
function log() {}
type StringRecord<TCollections> = { [x: string]: TCollections };
type Options = {
type IndexedDBOptions<TRegistrars extends Array<Registrars> = Array<any>> = {
name: string;
registrars: TRegistrars;
version?: number;
registrars: Registrars[];
log?: DBLogger;
};

View File

@@ -1,30 +1,18 @@
import type { IDBPDatabase } from "idb";
import { Query, update } from "mingo";
import type { Criteria, Options } from "mingo/types";
import type { CloneMode, Modifier } from "mingo/updater";
import type { Criteria } from "mingo/types";
import type { Modifier } from "mingo/updater";
import { type DBLogger, InsertLog, QueryLog, RemoveLog, ReplaceLog, UpdateLog } from "../../logger.ts";
import { getDocumentWithPrimaryKey } from "../../primary-key.ts";
import { DuplicateDocumentError } from "../../storage/errors.ts";
import {
getInsertManyResult,
getInsertOneResult,
type InsertManyResult,
type InsertOneResult,
} from "../../storage/operators/insert.ts";
import { RemoveResult } from "../../storage/operators/remove.ts";
import { UpdateResult } from "../../storage/operators/update.ts";
import { addOptions, type Index, type QueryOptions, Storage } from "../../storage/storage.ts";
import type { Document, Filter } from "../../types.ts";
import { type DBLogger, InsertLog, QueryLog, RemoveLog, UpdateLog } from "../../logger.ts";
import type { Index } from "../../registrars.ts";
import { addOptions, type QueryOptions, Storage, type UpdateResult } from "../../storage.ts";
import type { AnyDocument } from "../../types.ts";
import { IndexedDBCache } from "./cache.ts";
const OBJECT_PROTOTYPE = Object.getPrototypeOf({});
const OBJECT_TAG = "[object Object]";
export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Document = Document> extends Storage<
TPrimaryKey,
TSchema
> {
export class IndexedDBStorage<TSchema extends AnyDocument = AnyDocument> extends Storage<TSchema> {
readonly #cache = new IndexedDBCache<TSchema>();
readonly #promise: Promise<IDBPDatabase>;
@@ -33,11 +21,11 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
constructor(
name: string,
primaryKey: TPrimaryKey,
indexes: Index[],
promise: Promise<IDBPDatabase>,
readonly log: DBLogger,
readonly log: DBLogger = function log() {},
) {
super(name, primaryKey);
super(name, indexes);
this.#promise = promise;
}
@@ -69,45 +57,17 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
|--------------------------------------------------------------------------------
*/
async insertOne(values: TSchema | Omit<TSchema, TPrimaryKey>): Promise<InsertOneResult> {
async insert(documents: TSchema[]): Promise<void> {
const logger = new InsertLog(this.name);
const document = getDocumentWithPrimaryKey(this.primaryKey, values);
if (await this.has(document[this.primaryKey])) {
throw new DuplicateDocumentError(document, this as any);
}
await this.db.transaction(this.name, "readwrite", { durability: "relaxed" }).store.add(document);
this.broadcast("insertOne", document);
this.#cache.flush();
this.log(logger.result());
return getInsertOneResult(document);
}
async insertMany(values: (TSchema | Omit<TSchema, TPrimaryKey>)[]): Promise<InsertManyResult> {
const logger = new InsertLog(this.name);
const documents: TSchema[] = [];
const tx = this.db.transaction(this.name, "readwrite", { durability: "relaxed" });
await Promise.all(
values.map((values) => {
const document = getDocumentWithPrimaryKey(this.primaryKey, values);
documents.push(document);
return tx.store.add(document);
}),
);
await Promise.all(documents.map((document) => tx.store.add(document)));
await tx.done;
this.broadcast("insertMany", documents);
this.broadcast("insert", documents);
this.#cache.flush();
this.log(logger.result());
return getInsertManyResult(documents);
}
/*
@@ -116,28 +76,28 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
|--------------------------------------------------------------------------------
*/
async findById(id: string): Promise<TSchema | undefined> {
return this.db.getFromIndex(this.name, "id", id);
async getByIndex(index: string, value: string): Promise<TSchema[]> {
return this.db.getAllFromIndex(this.name, index, value);
}
async find(filter: Filter<TSchema>, options: QueryOptions = {}): Promise<TSchema[]> {
const logger = new QueryLog(this.name, { filter, options });
async find(condition: Criteria<TSchema> = {}, options?: QueryOptions): Promise<TSchema[]> {
const logger = new QueryLog(this.name, { condition, options });
const hashCode = this.#cache.hash(filter, options);
const hashCode = this.#cache.hash(condition, options);
const cached = this.#cache.get(hashCode);
if (cached !== undefined) {
this.log(logger.result({ cached: true }));
return cached;
}
const indexes = this.#resolveIndexes(filter);
let cursor = new Query(filter).find<TSchema>(await this.#getAll({ ...options, ...indexes }));
const indexes = this.#resolveIndexes(condition);
let cursor = new Query(condition).find<TSchema>(await this.#getAll({ ...options, ...indexes }));
if (options !== undefined) {
cursor = addOptions(cursor, options);
}
const documents = cursor.all() as TSchema[];
this.#cache.set(this.#cache.hash(filter, options), documents);
const documents = cursor.all();
this.#cache.set(this.#cache.hash(condition, options), documents);
this.log(logger.result());
@@ -172,10 +132,7 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
return {};
}
async #getAll({ index, offset, range, limit }: QueryOptions) {
if (index !== undefined) {
return this.#getAllByIndex(index);
}
async #getAll({ offset, range, limit }: QueryOptions) {
if (range !== undefined) {
return this.db.getAll(this.name, IDBKeyRange.bound(range.from, range.to));
}
@@ -185,23 +142,6 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
return this.db.getAll(this.name, undefined, limit);
}
async #getAllByIndex(index: Index) {
let result = new Set();
for (const key in index) {
const value = index[key];
if (Array.isArray(value)) {
for (const idx of value) {
const values = await this.db.getAllFromIndex(this.name, key, idx);
result = new Set([...result, ...values]);
}
} else {
const values = await this.db.getAllFromIndex(this.name, key, value);
result = new Set([...result, ...values]);
}
}
return Array.from(result);
}
async #getAllByOffset(value: string, direction: 1 | -1, limit?: number) {
if (direction === 1) {
return this.db.getAll(this.name, IDBKeyRange.lowerBound(value), limit);
@@ -233,33 +173,14 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
|--------------------------------------------------------------------------------
*/
async updateOne(
filter: Filter<TSchema>,
async update(
condition: Criteria<TSchema>,
modifier: Modifier<TSchema>,
arrayFilters?: Filter<TSchema>[],
condition?: Criteria<TSchema>,
options: { cloneMode?: CloneMode; queryOptions?: Partial<Options> } = { cloneMode: "deep" },
arrayFilters?: TSchema[],
): Promise<UpdateResult> {
if (typeof filter.id === "string") {
return this.#update(filter.id, modifier, arrayFilters, condition, options);
}
const documents = await this.find(filter);
if (documents.length > 0) {
return this.#update(documents[0].id, modifier, arrayFilters, condition, options);
}
return new UpdateResult(0, 0);
}
const logger = new UpdateLog(this.name, { condition, modifier, arrayFilters });
async updateMany(
filter: Filter<TSchema>,
modifier: Modifier<TSchema>,
arrayFilters?: Filter<TSchema>[],
condition?: Criteria<TSchema>,
options: { cloneMode?: CloneMode; queryOptions?: Partial<Options> } = { cloneMode: "deep" },
): Promise<UpdateResult> {
const logger = new UpdateLog(this.name, { filter, modifier, arrayFilters, condition, options });
const ids = await this.find(filter).then((data) => data.map((d) => d.id));
const ids = await this.find(condition).then((data) => data.map((d) => d.id));
const documents: TSchema[] = [];
let modifiedCount = 0;
@@ -271,7 +192,7 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
if (current === undefined) {
return;
}
const modified = update(current, modifier, arrayFilters, condition, options);
const modified = update(current, modifier, arrayFilters, condition, { cloneMode: "deep" });
if (modified.length > 0) {
modifiedCount += 1;
documents.push(current);
@@ -283,71 +204,12 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
await tx.done;
this.broadcast("updateMany", documents);
this.broadcast("update", documents);
this.#cache.flush();
this.log(logger.result());
return new UpdateResult(ids.length, modifiedCount);
}
async replace(filter: Filter<TSchema>, document: TSchema): Promise<UpdateResult> {
const logger = new ReplaceLog(this.name, document);
const ids = await this.find(filter).then((data) => data.map((d) => d.id));
const documents: TSchema[] = [];
const count = ids.length;
const tx = this.db.transaction(this.name, "readwrite", { durability: "relaxed" });
await Promise.all(
ids.map((id) => {
const next = { ...document, id };
documents.push(next);
return tx.store.put(next);
}),
);
await tx.done;
this.broadcast("updateMany", documents);
this.#cache.flush();
this.log(logger.result({ count }));
return new UpdateResult(count, count);
}
async #update(
id: string | number,
modifier: Modifier<TSchema>,
arrayFilters?: Filter<TSchema>[],
condition?: Criteria<TSchema>,
options: { cloneMode?: CloneMode; queryOptions?: Partial<Options> } = { cloneMode: "deep" },
): Promise<UpdateResult> {
const logger = new UpdateLog(this.name, { id, modifier });
const tx = this.db.transaction(this.name, "readwrite", { durability: "relaxed" });
const current = await tx.store.get(id);
if (current === undefined) {
await tx.done;
return new UpdateResult(0, 0);
}
const modified = await update(current, modifier, arrayFilters, condition, options);
if (modified.length > 0) {
await tx.store.put(current);
}
await tx.done;
if (modified.length > 0) {
this.broadcast("updateOne", current);
this.log(logger.result());
this.#cache.flush();
return new UpdateResult(1, 1);
}
return new UpdateResult(1);
return { matchedCount: ids.length, modifiedCount };
}
/*
@@ -356,10 +218,10 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
|--------------------------------------------------------------------------------
*/
async remove(filter: Filter<TSchema>): Promise<RemoveResult> {
const logger = new RemoveLog(this.name, { filter });
async remove(condition: Criteria<TSchema>): Promise<number> {
const logger = new RemoveLog(this.name, { condition });
const documents = await this.find(filter);
const documents = await this.find(condition);
const tx = this.db.transaction(this.name, "readwrite");
await Promise.all(documents.map((data) => tx.store.delete(data.id)));
@@ -370,7 +232,7 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
this.log(logger.result({ count: documents.length }));
return new RemoveResult(documents.length);
return documents.length;
}
/*
@@ -379,9 +241,9 @@ export class IndexedDBStorage<TPrimaryKey extends string, TSchema extends Docume
|--------------------------------------------------------------------------------
*/
async count(filter?: Filter<TSchema>): Promise<number> {
if (filter !== undefined) {
return (await this.find(filter)).length;
async count(condition: Criteria<TSchema>): Promise<number> {
if (condition !== undefined) {
return (await this.find(condition)).length;
}
return this.db.count(this.name);
}

View File

@@ -1,31 +1,49 @@
import { Collection } from "../../collection.ts";
import type { Document } from "../../types.ts";
import type { Registrars } from "../registrars.ts";
import type { Index, Registrars } from "../../registrars.ts";
import { MemoryStorage } from "./storage.ts";
type Options = {
name: string;
registrars: Registrars[];
};
export class MemoryDatabase<TOptions extends MemoryDatabaseOptions> {
readonly #collections = new Map<string, Collection>();
export class MemoryDatabase<T extends Record<string, Document>> {
readonly name: string;
readonly #collections = new Map<keyof T, Collection<T[keyof T]>>();
constructor(readonly options: Options) {
this.name = options.name;
for (const { name } of options.registrars) {
this.#collections.set(name, new Collection(name, new MemoryStorage(name)));
constructor(readonly options: TOptions) {
for (const { name, schema, indexes } of options.registrars) {
this.#collections.set(
name,
new Collection({
name,
storage: new MemoryStorage(name, indexes),
schema,
indexes,
}),
);
}
}
get name() {
return this.options.name;
}
get registrars() {
return this.options.registrars;
}
/*
|--------------------------------------------------------------------------------
| Fetchers
|--------------------------------------------------------------------------------
*/
collection<Name extends keyof T>(name: Name): Collection<T[Name]> {
collection<
TName extends TOptions["registrars"][number]["name"],
TSchema = Extract<TOptions["registrars"][number], { name: TName }>["schema"],
>(
name: TName,
): Collection<{
name: TName;
storage: MemoryStorage;
schema: TSchema;
indexes: Index[];
}> {
const collection = this.#collections.get(name);
if (collection === undefined) {
throw new Error(`Collection '${name as string}' not found`);
@@ -45,3 +63,8 @@ export class MemoryDatabase<T extends Record<string, Document>> {
}
}
}
type MemoryDatabaseOptions<TRegistrars extends Array<Registrars> = Array<any>> = {
name: string;
registrars: TRegistrars;
};

View File

@@ -1,155 +1,88 @@
import { Query, update } from "mingo";
import type { AnyObject } from "mingo/types";
import type { Criteria } from "mingo/types";
import type { Modifier } from "mingo/updater";
import { getDocumentWithPrimaryKey } from "../../primary-key.ts";
import { Collections } from "../../storage/collections.ts";
import type { UpdatePayload } from "../../storage/mod.ts";
import type { InsertResult } from "../../storage/operators/insert.ts";
import type { UpdateResult } from "../../storage/operators/update.ts";
import {
addOptions,
type CountPayload,
type FindByIdPayload,
type FindPayload,
type InsertManyPayload,
type InsertOnePayload,
type RemovePayload,
type ReplacePayload,
Storage,
} from "../../storage/storage.ts";
import { IndexManager, type IndexSpec } from "../../index/manager.ts";
import type { UpdateResult } from "../../storage.ts";
import { addOptions, type QueryOptions, Storage } from "../../storage.ts";
import type { AnyDocument } from "../../types.ts";
export class MemoryStorage extends Storage {
readonly #collections = new Collections();
export class MemoryStorage<TSchema extends AnyDocument = AnyDocument> extends Storage<TSchema> {
readonly index: IndexManager<TSchema>;
constructor(name: string, indexes: IndexSpec[]) {
super(name, indexes);
this.index = new IndexManager(indexes);
}
get documents() {
return this.index.primary.tree;
}
async resolve() {
return this;
}
async insertOne({ pkey, values, ...payload }: InsertOnePayload): Promise<InsertResult> {
const collection = this.#collections.get(payload.collection);
const document = getDocumentWithPrimaryKey(pkey, values);
if (collection.has(document[pkey])) {
return { insertCount: 0, insertIds: [] };
async insert(documents: TSchema[]): Promise<void> {
for (const document of documents) {
this.index.insert(document);
}
this.broadcast("insert", documents);
}
collection.set(document[pkey], document);
this.broadcast("insertOne", document);
return { insertCount: 1, insertIds: [document[pkey]] };
async getByIndex(index: string, value: string): Promise<TSchema[]> {
return this.index.get(index)?.get(value) ?? [];
}
async insertMany({ pkey, values, ...payload }: InsertManyPayload): Promise<InsertResult> {
const collection = this.#collections.get(payload.collection);
const documents: AnyDocument[] = [];
for (const insert of values) {
const document = getDocumentWithPrimaryKey(pkey, insert);
if (collection.has(document[pkey])) {
continue;
}
collection.set(document[pkey], document);
documents.push(document);
}
if (documents.length > 0) {
this.broadcast("insertMany", documents);
}
return { insertCount: documents.length, insertIds: documents.map((document) => document[pkey]) };
}
async findById({ collection, id }: FindByIdPayload): Promise<AnyObject | undefined> {
return this.#collections.get(collection).get(id);
}
async find({ condition = {}, options, ...payload }: FindPayload): Promise<AnyDocument[]> {
let cursor = new Query(condition).find<AnyDocument>(this.#collections.documents(payload.collection));
async find(condition: Criteria<TSchema> = {}, options?: QueryOptions): Promise<TSchema[]> {
let cursor = new Query(condition).find<TSchema>(this.documents);
if (options !== undefined) {
cursor = addOptions(cursor, options);
}
return cursor.all();
}
async updateOne({ pkey, condition, modifier, arrayFilters, ...payload }: UpdatePayload): Promise<UpdateResult> {
const collection = this.#collections.get(payload.collection);
async update(
condition: Criteria<TSchema>,
modifier: Modifier<TSchema>,
arrayFilters?: TSchema[],
): Promise<UpdateResult> {
const documents: TSchema[] = [];
let matchedCount = 0;
let modifiedCount = 0;
for (const document of await this.find({ collection: payload.collection, condition, options: { limit: 1 } })) {
const modified = update(document, modifier, arrayFilters, undefined, { cloneMode: "deep" });
if (modified.length > 0) {
collection.set(document[pkey], document);
this.broadcast("updateOne", document);
modifiedCount += 1;
}
matchedCount += 1;
}
return { matchedCount, modifiedCount };
}
async updateMany({ pkey, condition, modifier, arrayFilters, ...payload }: UpdatePayload): Promise<UpdateResult> {
const collection = this.#collections.get(payload.collection);
const documents: AnyDocument[] = [];
let matchedCount = 0;
let modifiedCount = 0;
for (const document of await this.find({ collection: payload.collection, condition })) {
for (const document of await this.find(condition)) {
matchedCount += 1;
const modified = update(document, modifier, arrayFilters, undefined, { cloneMode: "deep" });
if (modified.length > 0) {
modifiedCount += 1;
documents.push(document);
collection.set(document[pkey], document);
this.documents.add(document);
}
}
this.broadcast("updateMany", documents);
if (modifiedCount > 0) {
this.broadcast("update", documents);
}
return { matchedCount, modifiedCount };
}
async replace({ pkey, condition, document, ...payload }: ReplacePayload): Promise<UpdateResult> {
const collection = this.#collections.get(payload.collection);
let matchedCount = 0;
let modifiedCount = 0;
const documents: AnyDocument[] = [];
for (const current of await this.find({ collection: payload.collection, condition })) {
matchedCount += 1;
modifiedCount += 1;
documents.push(document);
collection.set(current[pkey], document);
}
this.broadcast("updateMany", documents);
return { matchedCount, modifiedCount };
}
async remove({ pkey, condition, ...payload }: RemovePayload): Promise<number> {
const collection = this.#collections.get(payload.collection);
const documents = await this.find({ collection: payload.collection, condition });
async remove(condition: Criteria<TSchema>): Promise<number> {
const documents = await this.find(condition);
for (const document of documents) {
collection.delete(document[pkey]);
this.documents.delete(document);
}
this.broadcast("remove", documents);
return documents.length;
}
async count({ collection, condition = {} }: CountPayload): Promise<number> {
return new Query(condition).find(this.#collections.documents(collection)).all().length;
async count(condition: Criteria<TSchema>): Promise<number> {
return new Query(condition).find(this.documents).all().length;
}
async flush(): Promise<void> {
this.#collections.flush();
this.documents.clear();
}
}

View File

@@ -0,0 +1,29 @@
import { describe, it } from "@std/testing/bdd";
import { expect } from "expect";
import { MemoryStorage } from "../storage.ts";
/*
|--------------------------------------------------------------------------------
| Unit Tests
|--------------------------------------------------------------------------------
*/
describe("Memory Storage", () => {
it("should insert new records", async () => {
const storage = new MemoryStorage("test", [["id", { primary: true }]]);
const documents = [
{
id: "abc",
foo: "bar",
},
];
await storage.insert(documents);
console.log(storage);
expect(storage.documents).toContain(documents);
});
});

View File

@@ -1,2 +0,0 @@
export * from "./indexeddb/database.ts";
export * from "./memory/database.ts";

View File

@@ -1,147 +0,0 @@
import { Query, update } from "mingo";
import type { Criteria, Options } from "mingo/types";
import type { CloneMode, Modifier } from "mingo/updater";
import { getDocumentWithPrimaryKey } from "../../primary-key.ts";
import { DuplicateDocumentError } from "../../storage/errors.ts";
import type { InsertResult } from "../../storage/operators/insert.ts";
import { UpdateResult } from "../../storage/operators/update.ts";
import { addOptions, type QueryOptions, Storage } from "../../storage/storage.ts";
import type { AnyDocument } from "../../types.ts";
export class ObserverStorage extends Storage {
readonly #documents = new Map<string, AnyDocument>();
async resolve() {
return this;
}
async has(id: string): Promise<boolean> {
return this.#documents.has(id);
}
async insertOne(values: AnyDocument): Promise<InsertResult> {
const document = getDocumentWithPrimaryKey(this.primaryKey, values);
if (await this.has(document[this.primaryKey])) {
throw new DuplicateDocumentError(document, this as any);
}
this.#documents.set(document[this.primaryKey], document);
return getInsertOneResult(document);
}
async insertMany(list: TSchema[]): Promise<InsertResult> {
const result: TSchema[] = [];
for (const values of list) {
const document = getDocumentWithPrimaryKey(this.primaryKey, values);
result.push(document);
this.#documents.set(document.id, document);
}
return getInsertManyResult(result);
}
async findById(id: string): Promise<TSchema | undefined> {
return this.#documents.get(id);
}
async find(filter?: Filter<TSchema>, options?: QueryOptions): Promise<TSchema[]> {
let cursor = new Query(filter ?? {}).find<TSchema>(Array.from(this.#documents.values()));
if (options !== undefined) {
cursor = addOptions(cursor, options);
}
return cursor.all();
}
async updateOne(
filter: Filter<TSchema>,
modifier: Modifier<TSchema>,
arrayFilters?: Filter<TSchema>[],
condition?: Criteria<TSchema>,
options: { cloneMode?: CloneMode; queryOptions?: Partial<Options> } = { cloneMode: "deep" },
): Promise<UpdateResult> {
const query = new Query(filter);
for (const document of Array.from(this.#documents.values())) {
if (query.test(document) === true) {
const modified = update(document, modifier, arrayFilters, condition, options);
if (modified.length > 0) {
this.#documents.set(document.id, document);
this.broadcast("updateOne", document);
return new UpdateResult(1, 1);
}
return new UpdateResult(1, 0);
}
}
return new UpdateResult(0, 0);
}
async updateMany(
filter: Filter<TSchema>,
modifier: Modifier<TSchema>,
arrayFilters?: Filter<TSchema>[],
condition?: Criteria<TSchema>,
options: { cloneMode?: CloneMode; queryOptions?: Partial<Options> } = { cloneMode: "deep" },
): Promise<UpdateResult> {
const query = new Query(filter);
const documents: TSchema[] = [];
let matchedCount = 0;
let modifiedCount = 0;
for (const document of Array.from(this.#documents.values())) {
if (query.test(document) === true) {
matchedCount += 1;
const modified = update(document, modifier, arrayFilters, condition, options);
if (modified.length > 0) {
modifiedCount += 1;
documents.push(document);
this.#documents.set(document.id, document);
}
}
}
this.broadcast("updateMany", documents);
return new UpdateResult(matchedCount, modifiedCount);
}
async replace(filter: Filter<TSchema>, document: TSchema): Promise<UpdateResult> {
const query = new Query(filter);
const documents: TSchema[] = [];
let matchedCount = 0;
let modifiedCount = 0;
for (const current of Array.from(this.#documents.values())) {
if (query.test(current) === true) {
matchedCount += 1;
modifiedCount += 1;
documents.push(document);
this.#documents.set(document.id, document);
}
}
return new UpdateResult(matchedCount, modifiedCount);
}
async remove(filter: Filter<TSchema>): Promise<RemoveResult> {
const documents = Array.from(this.#documents.values());
const query = new Query(filter);
let count = 0;
for (const document of documents) {
if (query.test(document) === true) {
this.#documents.delete(document.id);
count += 1;
}
}
return new RemoveResult(count);
}
async count(filter?: Filter<TSchema>): Promise<number> {
return new Query(filter ?? {}).find(Array.from(this.#documents.values())).all().length;
}
async flush(): Promise<void> {
this.#documents.clear();
}
}

View File

@@ -1,23 +0,0 @@
export type Registrars = {
/**
* Name of the collection.
*/
name: string;
/**
* Set the primary key of the collection.
* Default: "id"
*/
primaryKey?: string;
/**
* List of custom indexes for the collection.
*/
indexes?: Index[];
};
type Index = [IndexKey, IndexOptions?];
type IndexKey = string;
type IndexOptions = { unique: boolean };

View File

@@ -1,5 +1,11 @@
export function hashCodeQuery(filter: unknown, options: unknown): number {
const value = JSON.stringify({ filter, options });
/**
* Generate a number from the given condition and option combination.
*
* @param condition - Condition to hash.
* @param options - Options to hash.
*/
export function hashCodeQuery(condition: unknown, options: unknown): number {
const value = JSON.stringify({ condition, options });
let hash = 0;
if (value.length === 0) {
return hash;

117
src/index/manager.ts Normal file
View File

@@ -0,0 +1,117 @@
import type { Criteria } from "mingo/types";
import type { AnyDocument } from "../types.ts";
import { PrimaryIndex } from "./primary.ts";
import { SharedIndex } from "./shared.ts";
import { UniqueIndex } from "./unique.ts";
export class IndexManager<TSchema extends AnyDocument> {
readonly primary: PrimaryIndex<TSchema>;
readonly unique = new Map<keyof TSchema, UniqueIndex>();
readonly shared = new Map<keyof TSchema, SharedIndex>();
constructor(specs: IndexSpec[]) {
const primary = specs.find((spec) => spec.kind === "primary");
if (primary === undefined) {
throw new Error("Primary index is required");
}
this.primary = new PrimaryIndex(primary.field);
for (const spec of specs) {
switch (spec.kind) {
case "unique": {
this.unique.set(spec.field, new UniqueIndex());
break;
}
case "shared": {
this.shared.set(spec.field, new SharedIndex());
break;
}
}
}
}
insert(document: TSchema) {
const pk = document[this.primary.key];
for (const [field, index] of this.unique) {
index.insert(document[field], pk);
}
for (const [field, index] of this.shared) {
index.insert(document[field], pk);
}
this.primary.insert(pk, document);
}
getByCondition(condition: Criteria<TSchema>): TSchema[] | undefined {
// const pks = new Set<any>();
// for (const key in condition) {
// if (this.indexes.includes(key)) {
// if (key === this.primaryKey) {
// pks.add(condition[key]);
// } else {
// const
// }
// }
// }
return [];
}
getByPrimary(pk: string): TSchema | undefined {
return this.primary.get(pk);
}
getByUnique(field: keyof TSchema, value: any): TSchema | undefined {
const pk = this.unique.get(field)?.lookup(value);
if (pk !== undefined) {
return this.primary.get(pk);
}
}
getByIndex(field: keyof TSchema, value: any): TSchema[] {
if (this.unique.has(field)) {
const document = this.getByUnique(field, value);
if (document === undefined) {
this.unique.get(field)?.delete(value);
return [];
}
return [document];
}
const pks = this.shared.get(field)?.lookup(value);
if (pks === undefined) {
return [];
}
const documents: TSchema[] = [];
for (const pk of pks) {
const document = this.primary.get(pk);
if (document === undefined) {
this.shared.get(field)?.delete(value, pk);
} else {
documents.push(document);
}
}
return documents;
}
remove(pk: string) {
const document = this.primary.get(pk);
if (document === undefined) {
return;
}
for (const [field, index] of this.unique) {
index.delete(document[field]);
}
for (const [field, index] of this.shared) {
index.delete(document[field], pk);
}
this.primary.delete(pk);
}
}
export type IndexSpec = {
field: string;
kind: IndexKind;
};
type IndexKind = "primary" | "unique" | "shared";

24
src/index/primary.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { AnyDocument } from "../types.ts";
export type PrimaryKey = string;
export class PrimaryIndex<TSchema extends AnyDocument> {
readonly #index = new Map<PrimaryKey, TSchema>();
constructor(readonly key: string) {}
insert(pk: PrimaryKey, document: TSchema) {
if (this.#index.has(pk)) {
throw new Error(`Duplicate primary key: ${pk}`);
}
this.#index.set(pk, document);
}
get(pk: PrimaryKey): TSchema | undefined {
return this.#index.get(pk);
}
delete(pk: PrimaryKey) {
this.#index.delete(pk);
}
}

46
src/index/shared.ts Normal file
View File

@@ -0,0 +1,46 @@
import type { PrimaryKey } from "./primary.ts";
export class SharedIndex {
readonly #index = new Map<string, Set<PrimaryKey>>();
/**
* Add a value to a shared primary key index.
*
* @param value - Value to map the primary key to.
* @param pk - Primary key to add to the value set.
*/
insert(value: any, pk: PrimaryKey) {
let set = this.#index.get(value);
if (set === undefined) {
set = new Set();
this.#index.set(value, set);
}
set.add(pk);
}
/**
* Find a indexed primary key for the given value.
*
* @param value - Value to lookup a primary key for.
*/
lookup(value: any): Set<PrimaryKey> {
return this.#index.get(value) ?? new Set();
}
/**
* Delete a primary key from a indexed value.
*
* @param value - Value to remove primary key from.
* @param pk - Primary key to remove.
*/
delete(value: any, pk: PrimaryKey) {
const set = this.#index.get(value);
if (set === undefined) {
return;
}
set.delete(pk);
if (set.size === 0) {
this.#index.delete(value);
}
}
}

20
src/index/unique.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { PrimaryKey } from "./primary.ts";
export class UniqueIndex {
readonly #index = new Map<string, PrimaryKey>();
insert(value: any, pk: PrimaryKey) {
if (this.#index.has(value)) {
throw new Error(`Unique constraint violation: ${value}`);
}
this.#index.set(value, pk);
}
lookup(value: any): PrimaryKey | undefined {
return this.#index.get(value);
}
delete(value: any) {
this.#index.delete(value);
}
}

View File

@@ -40,10 +40,6 @@ export class UpdateLog extends LogEvent implements DBLogEvent {
readonly type = "update" as const;
}
export class ReplaceLog extends LogEvent implements DBLogEvent {
readonly type = "replace" as const;
}
export class RemoveLog extends LogEvent implements DBLogEvent {
readonly type = "remove" as const;
}
@@ -67,4 +63,4 @@ export type DBLogEvent = {
message?: string;
};
type DBLogEventType = InsertLog["type"] | UpdateLog["type"] | ReplaceLog["type"] | RemoveLog["type"] | QueryLog["type"];
type DBLogEventType = InsertLog["type"] | UpdateLog["type"] | RemoveLog["type"] | QueryLog["type"];

View File

@@ -1,4 +1,5 @@
export * from "./collection.ts";
export * from "./databases/mod.ts";
export * from "./storage/mod.ts";
export type { Document, Filter } from "./types.ts";
export * from "./databases/indexeddb/database.ts";
export * from "./databases/memory/database.ts";
export * from "./storage.ts";
export type { AnyDocument } from "./types.ts";

View File

@@ -1,10 +1,11 @@
import { Query } from "mingo";
import type { Criteria } from "mingo/types";
import type { Document, Filter, WithId } from "../types.ts";
import type { AnyDocument } from "../types.ts";
export function isMatch<TSchema extends Document = Document>(
document: WithId<TSchema>,
filter?: Filter<WithId<TSchema>>,
export function isMatch<TSchema extends AnyDocument = AnyDocument>(
document: TSchema,
condition?: Criteria<TSchema>,
): boolean {
return !filter || new Query(filter).test(document);
return condition === undefined || new Query(condition).test(document);
}

View File

@@ -1,28 +1,31 @@
import type { Subscription } from "@valkyr/event-emitter";
import type { Criteria } from "mingo/types";
import type { Collection } from "../collection.ts";
import type { Document, Filter, WithId } from "../types.ts";
import type { AnyDocument } from "../types.ts";
import { isMatch } from "./is-match.ts";
export function observeOne<TSchema extends Document = Document>(
collection: Collection<TSchema>,
filter: Filter<WithId<TSchema>>,
onChange: (document: Document | undefined) => void,
): {
unsubscribe: () => void;
} {
collection.findOne(filter).then(onChange);
const subscription = collection.observable.change.subscribe(({ type, data }) => {
export function observeOne<TCollection extends Collection>(
collection: TCollection,
condition: Criteria<AnyDocument>,
onChange: (document: AnyDocument | undefined) => void,
): Subscription {
collection.findOne(condition).then((document) => onChange(document));
return collection.onChange(({ type, data }) => {
switch (type) {
case "insertOne":
case "updateOne": {
if (isMatch<TSchema>(data, filter) === true) {
onChange(data);
case "insert":
case "update": {
for (const document of data) {
if (isMatch(document, condition) === true) {
onChange(document);
break;
}
}
break;
}
case "remove": {
for (const document of data) {
if (isMatch<TSchema>(document, filter) === true) {
if (isMatch(document, condition) === true) {
onChange(undefined);
break;
}
@@ -31,10 +34,4 @@ export function observeOne<TSchema extends Document = Document>(
}
}
});
return {
unsubscribe: () => {
subscription.unsubscribe();
},
};
}

View File

@@ -1,55 +1,79 @@
import type { Subscription } from "@valkyr/event-emitter";
import { Query } from "mingo";
import type { AnyObject, Criteria } from "mingo/types";
import type { Criteria } from "mingo/types";
import type { Collection } from "../collection.ts";
import { addOptions, type ChangeEvent, type QueryOptions } from "../storage/mod.ts";
import { addOptions, type ChangeEvent, type QueryOptions } from "../storage.ts";
import type { AnyDocument } from "../types.ts";
import { Store } from "./store.ts";
import { isMatch } from "./is-match.ts";
export function observe<TCollection extends Collection, TSchema extends AnyObject = TCollection["$schema"]>(
export function observe<TCollection extends Collection>(
collection: TCollection,
condition: Criteria<TSchema>,
condition: Criteria<AnyDocument>,
options: QueryOptions | undefined,
onChange: (documents: TSchema[], changed: TSchema[], type: ChangeEvent["type"]) => void,
): {
unsubscribe: () => void;
} {
const store = Store.create();
onChange: (documents: AnyDocument[], changed: AnyDocument[], type: ChangeEvent["type"]) => void,
): Subscription {
const documents = new Map<string | number, AnyDocument>();
let debounce: any;
collection.find(condition, options).then(async (documents) => {
const resolved = await store.resolve(documents);
onChange(resolved, resolved, "insertMany");
// ### Init
// Find the initial documents and send them to the change listener.
collection.findMany(condition, options).then(async (documents) => {
onChange(documents, documents, "insert");
});
// ### Subscriptions
const subscriptions = [
collection.observable.flush.subscribe(() => {
collection.onFlush(() => {
clearTimeout(debounce);
store.flush();
onChange([], [], "remove");
}),
collection.observable.change.subscribe(async ({ type, data }) => {
let changed: AnyObject[] = [];
collection.onChange(async ({ type, data }) => {
const changed: AnyDocument[] = [];
switch (type) {
case "insertOne":
case "updateOne": {
changed = await store[type](data, condition);
case "insert": {
for (const document of data) {
if (isMatch(document, condition)) {
documents.set(collection.getPrimaryKeyValue(document), document);
changed.push(document);
}
}
break;
}
case "update": {
for (const document of data) {
const id = collection.getPrimaryKeyValue(document);
if (documents.has(id)) {
if (isMatch(document, condition)) {
documents.set(id, document);
} else {
documents.delete(id);
}
changed.push(document);
} else if (isMatch(document, condition)) {
documents.set(id, document);
changed.push(document);
}
}
break;
}
case "insertMany":
case "updateMany":
case "remove": {
changed = await store[type](data, condition);
for (const document of data) {
if (isMatch(document, condition)) {
documents.delete(collection.getPrimaryKeyValue(document));
changed.push(document);
}
}
break;
}
}
if (changed.length > 0) {
clearTimeout(debounce);
debounce = setTimeout(() => {
store.getDocuments().then((documents) => {
onChange(applyQueryOptions(documents, options), changed, type);
});
onChange(applyQueryOptions(Array.from(documents.values()), options), changed, type);
}, 0);
}
}),
@@ -60,14 +84,13 @@ export function observe<TCollection extends Collection, TSchema extends AnyObjec
for (const subscription of subscriptions) {
subscription.unsubscribe();
}
store.destroy();
},
};
}
function applyQueryOptions(documents: AnyDocument[], options?: QueryOptions): AnyDocument[] {
if (options !== undefined) {
return addOptions(new Query({}).find<AnyDocument>(documents), options).all();
return addOptions<AnyDocument>(new Query({}).find<AnyDocument>(documents), options).all();
}
return documents;
}

View File

@@ -1,83 +0,0 @@
import { ObserverStorage } from "../databases/observer/storage.ts";
import type { Storage } from "../storage/mod.ts";
import type { AnyDocument } from "../types.ts";
import { isMatch } from "./is-match.ts";
export class Store {
private constructor(private storage: Storage) {}
static create() {
return new Store(new ObserverStorage(`observer[${crypto.randomUUID()}]`));
}
get destroy() {
return this.storage.destroy.bind(this.storage);
}
async resolve(documents: AnyDocument[]): Promise<AnyDocument[]> {
await this.storage.insertMany(documents);
return this.getDocuments();
}
async getDocuments(): Promise<AnyDocument[]> {
return this.storage.find();
}
async insertMany(documents: AnyDocument[], filter: Filter<AnyDocument>): Promise<AnyDocument[]> {
const matched = [];
for (const document of documents) {
matched.push(...(await this.insertOne(document, filter)));
}
return matched;
}
async insertOne(document: AnyDocument, filter: Filter<AnyDocument>): Promise<AnyDocument[]> {
if (isMatch<AnyDocument>(document, filter)) {
await this.storage.insertOne(document);
return [document];
}
return [];
}
async updateMany(documents: AnyDocument[], filter: Filter<AnyDocument>): Promise<AnyDocument[]> {
const matched = [];
for (const document of documents) {
matched.push(...(await this.updateOne(document, filter)));
}
return matched;
}
async updateOne(document: AnyDocument, filter: Filter<AnyDocument>): Promise<AnyDocument[]> {
if (await this.storage.has(document.id)) {
await this.#updateOrRemove(document, filter);
return [document];
} else if (isMatch<AnyDocument>(document, filter)) {
await this.storage.insertOne(document);
return [document];
}
return [];
}
async remove(documents: AnyDocument[]): Promise<AnyDocument[]> {
const matched = [];
for (const document of documents) {
if (isMatch<AnyDocument>(document, { id: document.id } as AnyDocument)) {
await this.storage.remove({ id: document.id } as AnyDocument);
matched.push(document);
}
}
return matched;
}
async #updateOrRemove(document: AnyDocument, filter: Filter<AnyDocument>): Promise<void> {
if (isMatch<AnyDocument>(document, filter)) {
await this.storage.replace({ id: document.id } as AnyDocument, document);
} else {
await this.storage.remove({ id: document.id } as AnyDocument);
}
}
flush() {
this.storage.flush();
}
}

View File

@@ -1,8 +0,0 @@
import type { AnyDocument } from "./types.ts";
export function getDocumentWithPrimaryKey<TPKey extends string>(pkey: TPKey, document: AnyDocument): AnyDocument {
if (Object.hasOwn(document, pkey) === true) {
return document;
}
return { [pkey]: crypto.randomUUID(), ...document };
}

18
src/registrars.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { ZodRawShape } from "zod";
export type Registrars<TSchema extends ZodRawShape = ZodRawShape> = {
/**
* Name of the collection.
*/
name: string;
/**
* Schema definition of the documents stored in the collection.
*/
schema: TSchema;
/**
* List of custom indexes for the collection.
*/
indexes: IndexSpec[];
};

231
src/storage.ts Normal file
View File

@@ -0,0 +1,231 @@
import { EventEmitter } from "@valkyr/event-emitter";
import type { Cursor } from "mingo/cursor";
import type { Criteria } from "mingo/types";
import type { Modifier } from "mingo/updater";
import { BroadcastChannel, type StorageBroadcast } from "./broadcast.ts";
import type { Index } from "./registrars.ts";
import type { AnyDocument } from "./types.ts";
type StorageEvent = "change" | "flush";
export abstract class Storage<TSchema extends AnyDocument = AnyDocument> {
readonly event = new EventEmitter<StorageEvent>();
status: Status = "loading";
readonly #channel: BroadcastChannel;
constructor(
/**
* Name of the collection the storage is holding documents for.
*/
readonly name: string,
/**
* List of indexes to optimize storage lookups.
*/
readonly indexes: Index[],
) {
if (primaryIndexCount(indexes) !== 1) {
throw new Error("Storage is missing or has more than 1 defined primaryIndex");
}
this.#channel = new BroadcastChannel(`@valkyr/db:${name}`);
this.#channel.onmessage = ({ data }: MessageEvent<StorageBroadcast>) => {
if (data.name !== this.name) {
return;
}
switch (data.type) {
case "flush": {
this.event.emit("flush");
break;
}
default: {
this.event.emit("change", data);
break;
}
}
};
}
/*
|--------------------------------------------------------------------------------
| Resolver
|--------------------------------------------------------------------------------
*/
abstract resolve(): Promise<this>;
/*
|--------------------------------------------------------------------------------
| Status
|--------------------------------------------------------------------------------
*/
is(status: Status): boolean {
return this.status === status;
}
/*
|--------------------------------------------------------------------------------
| Broadcaster
|--------------------------------------------------------------------------------
|
| Broadcast local changes with any change listeners in the current and other
| browser tabs and window.
|
*/
broadcast(type: "flush"): void;
broadcast(type: "insert" | "update" | "remove", data: TSchema[]): void;
broadcast(type: StorageBroadcast["type"], data?: TSchema[]): void {
switch (type) {
case "flush": {
this.event.emit("flush");
break;
}
default: {
this.event.emit("change", { type, data });
break;
}
}
this.#channel.postMessage({ name: this.name, type, data });
}
/*
|--------------------------------------------------------------------------------
| Operations
|--------------------------------------------------------------------------------
*/
/**
* Add list of documents to the storage engine.
*
* @param documents - Documents to add.
*/
abstract insert(documents: TSchema[]): Promise<void>;
/**
* Retrieve a list of documents by a index value.
*
* @param index - Index path to lookup.
* @param value - Value to match against the path.
*/
abstract getByIndex(index: string, value: string): Promise<TSchema[]>;
/**
* Retrieve a list of documents from the storage engine.
*
* @param condition - Mingo criteria to filter documents against.
* @param options - Additional query options.
*/
abstract find(condition?: Criteria<TSchema>, options?: QueryOptions): Promise<TSchema[]>;
/**
* Update documents matching the given condition.
*
* @param condition - Mingo criteria to filter documents to update.
* @param modifier - Modifications to apply to the filtered documents.
* @param arrayFilters - Custom filter.
*/
abstract update(
condition: Criteria<TSchema>,
modifier: Modifier<TSchema>,
arrayFilters?: TSchema[],
): Promise<UpdateResult>;
/**
* Remove documents matching the given condition.
*
* @param condition - Mingo criteria to filter documents to remove.
*/
abstract remove(condition: Criteria<TSchema>): Promise<number>;
/**
* Get document count matching given condition.
*
* @param condition - Mingo criteria to count document against.
*/
abstract count(condition: Criteria<TSchema>): Promise<number>;
/**
* Remove all documents in the storage.
*/
abstract flush(): Promise<void>;
/*
|--------------------------------------------------------------------------------
| Destructor
|--------------------------------------------------------------------------------
*/
destroy() {
this.#channel.close();
}
}
/*
|--------------------------------------------------------------------------------
| Utilities
|--------------------------------------------------------------------------------
*/
export function addOptions<TSchema extends AnyDocument = AnyDocument>(
cursor: Cursor<TSchema>,
options: QueryOptions,
): Cursor<TSchema> {
if (options.sort) {
cursor.sort(options.sort);
}
if (options.skip !== undefined) {
cursor.skip(options.skip);
}
if (options.limit !== undefined) {
cursor.limit(options.limit);
}
return cursor;
}
function primaryIndexCount(indexes: Index[]): number {
let count = 0;
for (const [, options] of indexes) {
if (options?.primary === true) {
count += 1;
}
}
return count;
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type Status = "loading" | "ready";
export type ChangeEvent<TSchema extends AnyDocument = AnyDocument> = {
type: "insert" | "update" | "remove";
data: TSchema[];
};
export type QueryOptions = {
sort?: {
[key: string]: 1 | -1;
};
skip?: number;
range?: {
from: string;
to: string;
};
offset?: {
value: string;
direction: 1 | -1;
};
limit?: number;
};
export type UpdateResult = {
matchedCount: number;
modifiedCount: number;
};

View File

@@ -1,33 +0,0 @@
import type { AnyObject } from "mingo/types";
import { CollectionNotFoundError } from "./errors.ts";
export class Collections {
#collections = new Map<string, Documents>();
has(name: string): boolean {
return this.#collections.has(name);
}
documents(name: string): AnyObject[] {
return Array.from(this.get(name).values());
}
get(name: string): Documents {
const collection = this.#collections.get(name);
if (collection === undefined) {
throw new CollectionNotFoundError(name);
}
return collection;
}
delete(name: string): boolean {
return this.#collections.delete(name);
}
flush() {
this.#collections.clear();
}
}
type Documents = Map<string, AnyObject>;

View File

@@ -1,36 +0,0 @@
import type { AnyObject } from "mingo/types";
export class DuplicateDocumentError extends Error {
readonly type = "DuplicateDocumentError";
constructor(
readonly collection: string,
readonly document: AnyObject,
) {
super(`Collection Insert Violation: Document '${document.id}' already exists in '${collection}' collection`);
}
}
export class CollectionNotFoundError extends Error {
readonly type = "CollectionNotFoundError";
constructor(readonly collection: string) {
super(`Collection Retrieve Violation: Collection '${collection}' does not exist`);
}
}
export class DocumentNotFoundError extends Error {
readonly type = "DocumentNotFoundError";
constructor(readonly criteria: AnyObject) {
super(`Collection Update Violation: Document matching criteria does not exists`);
}
}
export class PullUpdateArrayError extends Error {
readonly type = "PullUpdateArrayError";
constructor(document: string, key: string) {
super(`Collection Update Violation: Document '${document}' $pull operation failed, '${key}' is not an array`);
}
}

View File

@@ -1,5 +0,0 @@
export * from "./errors.ts";
export * from "./operators/insert.ts";
export * from "./operators/remove.ts";
export * from "./operators/update.ts";
export * from "./storage.ts";

View File

@@ -1,4 +0,0 @@
export type InsertResult = {
insertCount: number;
insertIds: (string | number | symbol)[];
};

View File

@@ -1,4 +0,0 @@
export type UpdateResult = {
matchedCount: number;
modifiedCount: number;
};

View File

@@ -1,246 +0,0 @@
import type { Cursor } from "mingo/cursor";
import type { AnyObject, Criteria } from "mingo/types";
import type { Modifier } from "mingo/updater";
import { Subject } from "rxjs";
import { BroadcastChannel, type StorageBroadcast } from "../broadcast.ts";
import type { Prettify } from "../types.ts";
import type { InsertResult } from "./operators/insert.ts";
import type { UpdateResult } from "./operators/update.ts";
export abstract class Storage {
readonly observable: {
change: Subject<ChangeEvent>;
flush: Subject<void>;
} = {
change: new Subject<ChangeEvent>(),
flush: new Subject<void>(),
};
status: Status = "loading";
readonly #channel: BroadcastChannel;
constructor(readonly name: string) {
this.#channel = new BroadcastChannel(`valkyr:db:${name}`);
this.#channel.onmessage = ({ data }: MessageEvent<StorageBroadcast>) => {
if (data.name !== this.name) {
return;
}
switch (data.type) {
case "flush": {
this.observable.flush.next();
break;
}
default: {
this.observable.change.next(data);
break;
}
}
};
}
/*
|--------------------------------------------------------------------------------
| Resolver
|--------------------------------------------------------------------------------
*/
abstract resolve(): Promise<this>;
/*
|--------------------------------------------------------------------------------
| Status
|--------------------------------------------------------------------------------
*/
is(status: Status): boolean {
return this.status === status;
}
/*
|--------------------------------------------------------------------------------
| Broadcaster
|--------------------------------------------------------------------------------
|
| Broadcast local changes with any change listeners in the current and other
| browser tabs and window.
|
*/
broadcast(type: StorageBroadcast["type"], data?: AnyObject | AnyObject[]): void {
switch (type) {
case "flush": {
this.observable.flush.next();
break;
}
default: {
this.observable.change.next({ type, data: data as any });
break;
}
}
this.#channel.postMessage({ name: this.name, type, data });
}
/*
|--------------------------------------------------------------------------------
| Operations
|--------------------------------------------------------------------------------
*/
abstract insertOne(payload: InsertOnePayload): Promise<InsertResult>;
abstract insertMany(payload: InsertManyPayload): Promise<InsertResult>;
abstract findById(payload: FindByIdPayload): Promise<AnyObject | undefined>;
abstract find(payload: FindPayload): Promise<AnyObject[]>;
abstract updateOne(payload: UpdatePayload): Promise<UpdateResult>;
abstract updateMany(payload: UpdatePayload): Promise<UpdateResult>;
abstract replace(payload: ReplacePayload): Promise<UpdateResult>;
abstract remove(payload: RemovePayload): Promise<number>;
abstract count(payload: CountPayload): Promise<number>;
abstract flush(): Promise<void>;
/*
|--------------------------------------------------------------------------------
| Destructor
|--------------------------------------------------------------------------------
*/
destroy() {
this.#channel.close();
}
}
/*
|--------------------------------------------------------------------------------
| Utilities
|--------------------------------------------------------------------------------
*/
export function addOptions<TSchema extends AnyObject = AnyObject>(
cursor: Cursor<TSchema>,
options: QueryOptions,
): Cursor<TSchema> {
if (options.sort) {
cursor.sort(options.sort);
}
if (options.skip !== undefined) {
cursor.skip(options.skip);
}
if (options.limit !== undefined) {
cursor.limit(options.limit);
}
return cursor;
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type Status = "loading" | "ready";
export type ChangeEvent =
| {
type: "insertOne" | "updateOne";
data: AnyObject;
}
| {
type: "insertMany" | "updateMany" | "remove";
data: AnyObject[];
};
export type QueryOptions = {
sort?: {
[key: string]: 1 | -1;
};
skip?: number;
range?: {
from: string;
to: string;
};
offset?: {
value: string;
direction: 1 | -1;
};
limit?: number;
index?: Index;
};
export type Index = {
[index: string]: any;
};
export type InsertOnePayload = Prettify<
CollectionPayload &
PrimaryKeyPayload & {
values: AnyObject;
}
>;
export type InsertManyPayload = Prettify<
CollectionPayload &
PrimaryKeyPayload & {
values: AnyObject[];
}
>;
export type FindByIdPayload = Prettify<
CollectionPayload & {
id: string;
}
>;
export type FindPayload = Prettify<
CollectionPayload & {
condition?: Criteria<AnyObject>;
options?: QueryOptions;
}
>;
export type UpdatePayload = Prettify<
CollectionPayload &
PrimaryKeyPayload & {
condition: Criteria<AnyObject>;
modifier: Modifier<AnyObject>;
arrayFilters?: AnyObject[];
}
>;
export type ReplacePayload = Prettify<
CollectionPayload &
PrimaryKeyPayload & {
condition: Criteria<AnyObject>;
document: AnyObject;
}
>;
export type RemovePayload = Prettify<
CollectionPayload &
PrimaryKeyPayload & {
condition: Criteria<AnyObject>;
}
>;
export type CountPayload = Prettify<
CollectionPayload & {
condition?: Criteria<AnyObject>;
}
>;
type CollectionPayload = {
collection: string;
};
type PrimaryKeyPayload = {
pkey: string;
};

View File

@@ -1,171 +1,11 @@
import type { BSONRegExp, BSONType } from "bson";
export type Prettify<T> = { [K in keyof T]: T[K] } & {};
export type AnyDocument = Record<string, any>;
export type Filter<TSchema> = {
[P in keyof TSchema]?: Condition<TSchema[P]>;
} & RootFilterOperators<TSchema> &
Record<string, any>;
export type UpdateFilter<TSchema> = {
$inc?: OnlyFieldsOfType<TSchema, number>;
$set?: MatchKeysAndValues<TSchema> | MatchKeysToFunctionValues<TSchema> | Record<string, any>;
$unset?: OnlyFieldsOfType<TSchema, any, "" | true | 1>;
$pull?: PullOperator<TSchema>;
$push?: PushOperator<TSchema>;
};
type RootFilterOperators<TSchema> = {
$and?: Filter<TSchema>[];
$nor?: Filter<TSchema>[];
$or?: Filter<TSchema>[];
$text?: {
$search: string;
$language?: string;
$caseSensitive?: boolean;
$diacriticSensitive?: boolean;
};
$where?: string | ((this: TSchema) => boolean);
$comment?: string | Document;
};
type Condition<T> = AlternativeType<T> | FilterOperators<AlternativeType<T>>;
type AlternativeType<T> = T extends ReadonlyArray<infer U> ? T | RegExpOrString<U> : RegExpOrString<T>;
type RegExpOrString<T> = T extends string ? BSONRegExp | RegExp | T : T;
type FilterOperators<TValue> = {
$eq?: TValue;
$gt?: TValue;
$gte?: TValue;
$in?: ReadonlyArray<TValue>;
$lt?: TValue;
$lte?: TValue;
$ne?: TValue;
$nin?: ReadonlyArray<TValue>;
$not?: TValue extends string ? FilterOperators<TValue> | RegExp : FilterOperators<TValue>;
/**
* When `true`, `$exists` matches the documents that contain the field,
* including documents where the field value is null.
/**
* Represents an unknown document with global support.
*/
$exists?: boolean;
$type?: BSONType | BSONTypeAlias;
$expr?: Record<string, any>;
$jsonSchema?: Record<string, any>;
$mod?: TValue extends number ? [number, number] : never;
$regex?: TValue extends string ? RegExp | string : never;
$options?: TValue extends string ? string : never;
$geoIntersects?: {
$geometry: Document;
};
$geoWithin?: Document;
$near?: Document;
$nearSphere?: Document;
$maxDistance?: number;
$all?: ReadonlyArray<any>;
$elemMatch?: Document;
$size?: TValue extends ReadonlyArray<any> ? number : never;
$bitsAllClear?: BitwiseFilter;
$bitsAllSet?: BitwiseFilter;
$bitsAnyClear?: BitwiseFilter;
$bitsAnySet?: BitwiseFilter;
$rand?: Record<string, never>;
export type AnyDocument = {
[key: string]: any;
};
type BSONTypeAlias = keyof typeof BSONType;
type BitwiseFilter = number | ReadonlyArray<number>;
type OnlyFieldsOfType<TSchema, FieldType = any, AssignableType = FieldType> = IsAny<
TSchema[keyof TSchema],
Record<string, FieldType>,
AcceptedFields<TSchema, FieldType, AssignableType> &
NotAcceptedFields<TSchema, FieldType> &
Record<string, AssignableType>
>;
type MatchKeysAndValues<TSchema> = Readonly<Partial<TSchema>>;
type MatchKeysToFunctionValues<TSchema> = {
readonly [key in keyof TSchema]?: (this: TSchema, value: TSchema[key]) => TSchema[key];
};
type PullOperator<TSchema> = ({
readonly [key in KeysOfAType<TSchema, ReadonlyArray<any>>]?:
| Partial<Flatten<TSchema[key]>>
| FilterOperations<Flatten<TSchema[key]>>;
} & NotAcceptedFields<TSchema, ReadonlyArray<any>>) & {
readonly [key: string]: FilterOperators<any> | any;
};
type PushOperator<TSchema> = ({
readonly [key in KeysOfAType<TSchema, ReadonlyArray<any>>]?:
| Flatten<TSchema[key]>
| ArrayOperator<Array<Flatten<TSchema[key]>>>;
} & NotAcceptedFields<TSchema, ReadonlyArray<any>>) & {
readonly [key: string]: ArrayOperator<any> | any;
};
type KeysOfAType<TSchema, Type> = {
[key in keyof TSchema]: NonNullable<TSchema[key]> extends Type ? key : never;
}[keyof TSchema];
type AcceptedFields<TSchema, FieldType, AssignableType> = {
readonly [key in KeysOfAType<TSchema, FieldType>]?: AssignableType;
};
type NotAcceptedFields<TSchema, FieldType> = {
readonly [key in KeysOfOtherType<TSchema, FieldType>]?: never;
};
type Flatten<Type> = Type extends ReadonlyArray<infer Item> ? Item : Type;
type IsAny<Type, ResultIfAny, ResultIfNotAny> = true extends false & Type ? ResultIfAny : ResultIfNotAny;
type FilterOperations<T> =
T extends Record<string, any>
? {
[key in keyof T]?: FilterOperators<T[key]>;
}
: FilterOperators<T>;
type ArrayOperator<Type> = {
$each?: Array<Flatten<Type>>;
$slice?: number;
$position?: number;
$sort?: Sort;
};
type Sort =
| string
| Exclude<
SortDirection,
{
$meta: string;
}
>
| string[]
| {
[key: string]: SortDirection;
}
| Map<string, SortDirection>
| [string, SortDirection][]
| [string, SortDirection];
type SortDirection =
| 1
| -1
| "asc"
| "desc"
| "ascending"
| "descending"
| {
$meta: string;
};
type KeysOfOtherType<TSchema, Type> = {
[key in keyof TSchema]: NonNullable<TSchema[key]> extends Type ? never : key;
}[keyof TSchema];
/**
* Simplifies a complex type.
*/
export type Prettify<T> = { [K in keyof T]: T[K] } & {};

View File

@@ -1,7 +1,4 @@
import { clone } from "../src/clone.ts";
import type { WithId } from "../src/types.ts";
const users: WithId<UserDocument>[] = [
const users: UserDocument[] = [
{
id: "user-1",
name: "John Doe",
@@ -28,11 +25,12 @@ const users: WithId<UserDocument>[] = [
},
];
export function getUsers(): WithId<UserDocument>[] {
return clone(users);
export function getUsers(): UserDocument[] {
return JSON.parse(JSON.stringify(users));
}
export type UserDocument = {
id: string;
name: string;
email: string;
friends: Friend[];