diff --git a/README.md b/README.md index e65cf6a..da28b88 100644 --- a/README.md +++ b/README.md @@ -51,25 +51,33 @@ _Supported Deno verisons:_ **^1.43.0** - [updateByPrimaryIndex()](#updatebyprimaryindex) - [updateBySecondaryIndex()](#updatebysecondaryindex) - [updateMany()](#updatemany) + - [updateManyBySecondaryOrder()](#updatemanybysecondaryorder) - [updateOne()](#updateone) - [updateOneBySecondaryIndex()](#updateonebysecondaryindex) + - [updateOneBySecondaryOrder()](#updateonebysecondaryorder) - [upsert()](#upsert) - [upsertByPrimaryIndex()](#upsertbyprimaryindex) - [delete()](#delete) - [deleteByPrimaryIndex()](#deletebyprimaryindex) - [deleteBySecondaryIndex()](#deletebysecondaryindex) - [deleteMany()](#deletemany) + - [deleteManyBySecondaryOrder()](#deletemanybysecondaryorder) - [deleteHistory()](#deletehistory) - [deleteUndelivered()](#deleteundelivered) - [getMany()](#getmany) + - [getManyBySecondaryOrder()](#getmanybysecondaryorder) - [getOne()](#getone) - [getOneBySecondaryIndex()](#getonebysecondaryindex) + - [getOneBySecondaryOrder()](#getonebysecondaryorder) - [forEach()](#foreach) - [forEachBySecondaryIndex()](#foreachbysecondaryindex) + - [forEachBySecondaryOrder()](#foreachbysecondaryorder) - [map()](#map) - [mapBySecondaryIndex()](#mapbysecondaryindex) + - [mapBySecondaryOrder()](#mapbysecondaryorder) - [count()](#count) - [countBySecondaryIndex()](#countbysecondaryindex) + - [countBySecondaryOrder()](#countbysecondaryorder) - [enqueue()](#enqueue) - [listenQueue()](#listenqueue) - [watch()](#watch) @@ -101,6 +109,7 @@ _Supported Deno verisons:_ **^1.43.0** - [Migrate](#migrate) - [Script](#script) - [Function](#function) + - [KV](#kv) - [Blob Storage](#blob-storage) - [Development](#development) - [License](#license) @@ -553,6 +562,16 @@ const { result } = await db.users.updateMany({ age: 67 }, { const { result } = await db.users.updateMany({ username: "oliver" }) ``` +### updateManyBySecondaryOrder() + +Update the value of multiple existing documents in the collection by a secondary +order. + +```ts +// Updates the first 10 users ordered by age and sets username = "anon" +await db.users.updateManyBySecondaryOrder("age", { username: "anon" }) +``` + ### updateOne() Update the first matching document from the KV store. It optionally takes the @@ -597,6 +616,18 @@ const result = await db.users.updateOneBySecondaryIndex( ) ``` +### updateOneBySecondaryOrder() + +Update the value of one existing document in the collection by a secondary +order. + +```ts +// Updates the first user ordered by age and sets username = "anon" +const result = await db.users.updateOneBySecondaryOrder("age", { + username: "anon", +}) +``` + ### upsert() Update an existing document by id, or set a new document entry if no matching @@ -706,6 +737,18 @@ await db.users.deleteMany({ }) ``` +### deleteManyBySecondaryOrder() + +Delete multiple documents from the KV store by a secondary order. The method +takes an optional options argument that can be used for filtering of documents, +and pagination. If no options are provided, all documents in the collection are +deleted. + +```ts +// Deletes the first 10 users ordered by age +await db.users.deleteManyBySecondaryOrder("age", { limit: 10 }) +``` + ### deleteHistory() Delete the version history of a document by id. @@ -750,6 +793,22 @@ const { result } = await db.users.getMany({ }) ``` +### getManyBySecondaryOrder() + +Retrieves multiple documents from the KV store in the specified secondary order +and according to the given options. If no options are provided, all documents +are retrieved. + +```ts +// Get all users ordered by age +const { result } = await db.users.getManyBySecondaryOrder("age") + +// Only get users with username that starts with "a", ordered by age +const { result } = await db.users.getManyBySecondaryOrder("age", { + filter: (doc) => doc.value.username.startsWith("a"), +}) +``` + ### getOne() Retrieve the first matching document from the KV store. It optionally takes the @@ -783,6 +842,17 @@ const user = await db.users.getOneBySecondaryIndex("age", 20, { }) ``` +### getOneBySecondaryOrder() + +Retrieves one document from the KV store by a secondary order and according to +the given options. If no options are provided, the first document in the +collection by the given order is retrieved. + +```ts +// Get the first user ordered by age +const user = await db.users.getOneBySecondaryOrder("age") +``` + ### forEach() Execute a callback function for multiple documents in the KV store. Takes an @@ -827,6 +897,20 @@ await db.users.forEachBySecondaryIndex( ) ``` +### forEachBySecondaryOrder() + +Executes a callback function for every document by a secondary order and +according to the given options. If no options are provided, the callback +function is executed for all documents. + +```ts +// Prints the username of all users ordered by age +await db.users.forEachBySecondaryOrder( + "age", + (doc) => console.log(doc.value.username), +) +``` + ### map() Execute a callback function for multiple documents in the KV store and retrieve @@ -871,6 +955,21 @@ const { result } = await db.users.mapBySecondaryIndex( ) ``` +### mapBySecondaryOrder() + +Executes a callback function for every document by a secondary order and +according to the given options. If no options are provided, the callback +function is executed for all documents. The results from the callback function +are returned as a list. + +```ts +// Returns a list of usernames of all users ordered by age +const { result } = await db.users.mapBySecondaryOrder( + "age", + (doc) => doc.value.username, +) +``` + ### count() Count the number of documents in a collection. Takes an optional options @@ -898,6 +997,18 @@ options are given, it will count all documents matching the index. const count = await db.users.countBySecondaryIndex("age", 20) ``` +### countBySecondaryOrder() + +Counts the number of documents in the collection by a secondary order. + +```ts +// Counts how many of the first 10 users ordered by age that are under the age of 18 +const count = await db.users.countBySecondaryOrder("age", { + limit: 10, + filter: (doc) => doc.value.age < 18, +}) +``` + ### enqueue() Add data to the collection queue to be delivered to the queue listener via @@ -1400,6 +1511,32 @@ await migrate({ }) ``` +### KV + +Support for alternative KV backends, such as `Map` and `localStorage`. Can be +used to employ `kvdex` in the browser or other environments where Deno's KV +store is not available, or to adapt to other database backends. + +```ts +import { kvdex } from "@olli/kvdex" +import { MapKv } from "@olli/kvdex/kv" + +// Create a database from a `MapKv` instance, using `Map` as it's backend by default. +const kv = new MapKv() // Equivalent to `new MapKv({ map: new Map() })` +const db = kvdex(kv, {}) +``` + +```ts +import { kvdex } from "@olli/kvdex" +import { MapKv, StorageAdapter } from "@olli/kvdex/kv" + +// Create an ephimeral database from a `MapKv` instance, +// explicitly using `localStorage` as it's backend. +const map = new StorageAdapter(localStorage) +const kv = new MapKv({ map, clearOnClose: true }) +const db = kvdex(kv, {}) +``` + ## Blob Storage To store large blob sizes, and bypass the data limit of a single atomic diff --git a/deno.json b/deno.json index 3e29c51..22c43ff 100644 --- a/deno.json +++ b/deno.json @@ -1,13 +1,14 @@ { "name": "@olli/kvdex", - "version": "2.0.2", + "version": "2.1.0", "exports": { ".": "./mod.ts", - "./zod": "./ext/zod.ts", - "./migrate": "./ext/migrate.ts" + "./zod": "./src/ext/zod/mod.ts", + "./migrate": "./src/ext/migrate/mod.ts", + "./kv": "./src/ext/kv/mod.ts" }, "tasks": { - "check": "deno check mod.ts src/*.ts ext/*.ts tests/*.ts tests/**/*.ts benchmarks/**/*ts", + "check": "deno check mod.ts src/*.ts tests/**/*.ts benchmarks/**/*.ts", "test": "deno test --allow-write --allow-read --allow-ffi --allow-sys --unstable-kv --trace-leaks", "bench": "deno bench --unstable-kv", "prep": "deno task check && deno fmt && deno lint && deno publish --dry-run --allow-dirty && deno task test", diff --git a/deno.lock b/deno.lock index 1c2175f..f85a8b2 100644 --- a/deno.lock +++ b/deno.lock @@ -4,8 +4,10 @@ "specifiers": { "jsr:@std/assert@^0.217": "jsr:@std/assert@0.217.0", "jsr:@std/assert@^0.217.0": "jsr:@std/assert@0.217.0", + "jsr:@std/assert@^0.220.1": "jsr:@std/assert@0.220.1", "jsr:@std/bytes@^1.0.1": "jsr:@std/bytes@1.0.2", "jsr:@std/cli@^0.217": "jsr:@std/cli@0.217.0", + "jsr:@std/cli@^0.220": "jsr:@std/cli@0.220.1", "jsr:@std/collections@^1.0.2": "jsr:@std/collections@1.0.5", "jsr:@std/fmt@^0.217.0": "jsr:@std/fmt@0.217.0", "jsr:@std/ulid@^0.224.1": "jsr:@std/ulid@0.224.1", @@ -21,6 +23,9 @@ "jsr:@std/fmt@^0.217.0" ] }, + "@std/assert@0.220.1": { + "integrity": "88710d54f3afdd7a5761e7805abba1f56cd14e4b212feffeb3e73a9f77482425" + }, "@std/bytes@1.0.1": { "integrity": "e57c9b243932b95a4c3672f8a038cdadea7492efeeb6b8a774844fee70426815" }, @@ -33,6 +38,12 @@ "jsr:@std/assert@^0.217.0" ] }, + "@std/cli@0.220.1": { + "integrity": "6c38388efc899c9d98811d38a46effe01e0d91138068483f1e6ae834caad5e82", + "dependencies": [ + "jsr:@std/assert@^0.220.1" + ] + }, "@std/collections@1.0.2": { "integrity": "cb1834be534f967e86495f6c64fa06f6b3debdaab5b860c2d3194db59cd30157" }, diff --git a/src/collection.ts b/src/collection.ts index 514d9c4..319512a 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -81,7 +81,7 @@ import { import { AtomicWrapper } from "./atomic_wrapper.ts" import { AtomicPool } from "./atomic_pool.ts" import { Document } from "./document.ts" -import { model } from "./model.ts" +import { model as m } from "./model.ts" import { concat, deepMerge, ulid } from "./deps.ts" import { v8Serialize } from "./utils.ts" import { v8Deserialize } from "./utils.ts" @@ -120,7 +120,7 @@ export function collection< const TOutput extends KvValue, const TOptions extends CollectionOptions, >( - model: Model, + model: Model = m(), options?: TOptions, ): BuilderFn { return ( @@ -987,7 +987,7 @@ export class Collection< * * @example * ```ts - * // Updates all user documents and sets name = 67 + * // Updates all user documents and sets age = 67 * await db.users.updateMany({ age: 67 }) * ``` * @@ -1031,6 +1031,46 @@ export class Collection< ) } + /** + * Update the value of multiple existing documents in the collection by a secondary order. + * + * @example + * ```ts + * // Updates the first 10 users ordered by age and sets username = "anon" + * await db.users.updateManyBySecondaryOrder("age", { username: "anon" }) + * ``` + * + * @param order - Secondary order to update documents by. + * @param data - Updated data to be inserted into documents. + * @param options - Update many options, optional. + * @returns Promise resolving to an object containing iterator cursor and result list. + */ + async updateManyBySecondaryOrder< + const K extends SecondaryIndexKeys, + const T extends UpdateManyOptions< + Document>, + ParseId + >, + >( + order: K, + data: UpdateData, + options?: T, + ): Promise< + PaginationResult< + CommitResult> | DenoKvCommitError + > + > { + // Create prefix key + const prefixKey = extendKey(this._keys.secondaryIndex, order as KvId) + + // Update each document by secondary index, add commit result to result list + return await this.handleMany( + prefixKey, + (doc) => this.updateDocument(doc, data, options), + options, + ) + } + /** * Update the value of one existing document in the collection. * @@ -1131,6 +1171,47 @@ export class Collection< } } + /** + * Update the value of one existing document in the collection by a secondary order. + * + * @example + * ```ts + * // Updates the first user ordered by age and sets username = "anon" + * const result = await db.users.updateOneBySecondaryOrder("age", { username: "anon" }) + * ``` + * + * @param order - Secondary order to update document by. + * @param data - Updated data to be inserted into document. + * @param options - Update many options, optional. + * @returns Promise resolving to either a commit result or commit error object. + */ + async updateOneBySecondaryOrder< + const T extends UpdateOneOptions< + Document>, + ParseId + >, + const K extends SecondaryIndexKeys, + >( + order: K, + data: UpdateData, + options?: T, + ): Promise> | DenoKvCommitError> { + // Create prefix key + const prefixKey = extendKey(this._keys.secondaryIndex, order as KvId) + + // Update a single document + const { result } = await this.handleMany( + prefixKey, + (doc) => this.updateDocument(doc, data, options), + { ...options, take: 1 }, + ) + + // Return first result, or commit error object if not present + return result.at(0) ?? { + ok: false, + } + } + /** * Adds multiple documents to the KV store with generated ids. * @@ -1267,6 +1348,46 @@ export class Collection< return { cursor } } + /** + * Delete multiple documents from the KV store by a secondary order. + * + * The method takes an optional options argument that can be used for filtering of documents, and pagination. + * + * If no options are provided, all documents in the collection are deleted. + * + * @example + * ```ts + * // Deletes the first 10 users ordered by age + * await db.users.deleteManyBySecondaryOrder("age", { limit: 10 }) + * ``` + * + * @param order - Secondary order to delete documents by. + * @param options - List options, optional. + * @returns A promise that resolves to void. + */ + async deleteManyBySecondaryOrder< + const K extends SecondaryIndexKeys, + >( + order: K, + options?: ListOptions< + Document>, + ParseId + >, + ): Promise { + // Create prefix key + const prefixKey = extendKey(this._keys.secondaryIndex, order as KvId) + + // Delete documents by secondary index, return iterator cursor + const { cursor } = await this.handleMany( + prefixKey, + (doc) => this.deleteDocuments([doc.id], this._keepsHistory), + options, + ) + + // Return iterator cursor + return { cursor } + } + /** * Retrieves multiple documents from the KV store according to the given options. * @@ -1300,6 +1421,44 @@ export class Collection< ) } + /** + * Retrieves multiple documents from the KV store in the specified + * secondary order and according to the given options. + * + * If no options are provided, all documents are retrieved. + * + * @example + * ```ts + * // Get all users ordered by age + * const { result } = await db.users.getManyBySecondaryOrder("age") + * + * // Only get users with username that starts with "a", ordered by age + * const { result } = await db.users.getManyBySecondaryOrder("age", { + * filter: doc => doc.value.username.startsWith("a") + * }) + * ``` + * + * @param order - Secondary order to retrieve documents by. + * @param options - List options, optional. + * @returns A promise that resovles to an object containing a list of the retrieved documents and the iterator cursor + */ + async getManyBySecondaryOrder< + const K extends SecondaryIndexKeys, + >( + order: K, + options?: ListOptions< + Document>, + ParseId + >, + ): Promise>>> { + const prefixKey = extendKey(this._keys.secondaryIndex, order as KvId) + return await this.handleMany( + prefixKey, + (doc) => doc, + options, + ) + } + /** * Retrieves one document from the KV store according to the given options. * @@ -1328,7 +1487,7 @@ export class Collection< ParseId >, ): Promise> | null> { - // Get result list with limit of one item + // Get result list with one item const { result } = await this.handleMany( this._keys.id, (doc) => doc, @@ -1342,7 +1501,7 @@ export class Collection< /** * Retrieves one document from the KV store by a secondary index and according to the given options. * - * If no options are given, the first document in the collection by the given index is retreived. + * If no options are given, the first document in the collection by the given index is retrieved. * * @example * ```ts @@ -1380,7 +1539,45 @@ export class Collection< this, ) - // Get result list with limit of one item + // Get result list with one item + const { result } = await this.handleMany( + prefixKey, + (doc) => doc, + { ...options, take: 1 }, + ) + + // Return first result item, or null if not present + return result.at(0) ?? null + } + + /** + * Retrieves one document from the KV store by a secondary order and according to the given options. + * + * If no options are provided, the first document in the collection by the given order is retrieved. + * + * @example + * ```ts + * // Get the first user ordered by age + * const user = await db.users.getOneBySecondaryOrder("age") + * ``` + * + * @param order - Secondary order to retrieve document by. + * @param options - List options, optional. + * @returns A promise resolving to either a document or null. + */ + async getOneBySecondaryOrder< + const K extends SecondaryIndexKeys, + >( + order: K, + options?: HandleOneOptions< + Document>, + ParseId + >, + ): Promise> | null> { + // Create prefix key + const prefixKey = extendKey(this._keys.secondaryIndex, order as KvId) + + // Get result list with one item const { result } = await this.handleMany( prefixKey, (doc) => doc, @@ -1479,6 +1676,49 @@ export class Collection< return { cursor } } + /** + * Executes a callback function for every document by a secondary order and according to the given options. + * + * If no options are provided, the callback function is executed for all documents. + * + * @example + * ```ts + * // Prints the username of all users ordered by age + * await db.users.forEachBySecondaryOrder( + * "age", + * (doc) => console.log(doc.value.username), + * ) + * ``` + * + * @param order - Secondary order to retrieve documents by. + * @param fn - Callback function. + * @param options - List options, optional. + * @returns A promise that resovles to an object containing the iterator cursor. + */ + async forEachBySecondaryOrder< + const K extends SecondaryIndexKeys, + >( + order: K, + fn: (doc: Document>) => unknown, + options?: UpdateManyOptions< + Document>, + ParseId + >, + ): Promise { + // Create prefix key + const prefixKey = extendKey(this._keys.secondaryIndex, order as KvId) + + // Execute callback function for each document entry + const { cursor } = await this.handleMany( + prefixKey, + (doc) => fn(doc), + options, + ) + + // Return iterator cursor + return { cursor } + } + /** * Executes a callback function for every document according to the given options. * @@ -1570,6 +1810,49 @@ export class Collection< ) } + /** + * Executes a callback function for every document by a secondary order and according to the given options. + * + * If no options are provided, the callback function is executed for all documents. + * + * The results from the callback function are returned as a list. + * + * @example + * ```ts + * // Returns a list of usernames of all users ordered by age + * const { result } = await db.users.mapBySecondaryOrder( + * "age", + * (doc) => doc.value.username, + * ) + * ``` + * + * @param order - Secondary order to map documents by. + * @param fn - Callback function. + * @param options - List options, optional. + * @returns A promise that resovles to an object containing a list of the callback results and the iterator cursor. + */ + async mapBySecondaryOrder< + const T, + const K extends SecondaryIndexKeys, + >( + order: K, + fn: (doc: Document>) => T, + options?: UpdateManyOptions< + Document>, + ParseId + >, + ): Promise>> { + // Create prefix key + const prefixKey = extendKey(this._keys.secondaryIndex, order as KvId) + + // Execute callback function for each document entry, return result and cursor + return await this.handleMany( + prefixKey, + (doc) => fn(doc), + options, + ) + } + /** * Counts the number of documents in the collection. * @@ -1660,6 +1943,49 @@ export class Collection< return result } + /** + * Counts the number of documents in the collection by a secondary order. + * + * @example + * + * ```ts + * // Counts how many of the first 10 users ordered by age that are under the age of 18 + * const count = await db.users.countBySecondaryOrder("age", { + * limit: 10, + * filter: (doc) => doc.value.age < 18 + * }) + * ``` + * + * @param order - Secondary order to count documents by. + * @param options - Count options. + * @returns A promise that resolves to a number representing the count. + */ + async countBySecondaryOrder< + const K extends SecondaryIndexKeys, + >( + order: K, + options?: ListOptions< + Document>, + ParseId + >, + ): Promise { + // Create prefix key + const prefixKey = extendKey(this._keys.secondaryIndex, order as KvId) + + // Initialize count result + let result = 0 + + // Update each document by secondary index, add commit result to result list + await this.handleMany( + prefixKey, + () => result++, + options, + ) + + // Return count result + return result + } + /** * Add data to the collection queue to be delivered to the queue listener * via ``db.collection.listenQueue()``. The data will only be received by queue @@ -1771,7 +2097,7 @@ export class Collection< } // Return document - return new Document(model(), { + return new Document(m(), { id, versionstamp: result.versionstamp, value: result.value as T, @@ -2169,29 +2495,20 @@ export class Collection< this, ) + await deleteIndices( + id, + doc.value as KvObject, + atomic, + this, + ) + const cr = await atomic.commit() - // If check fails, return commit error if (!cr.ok) { return { ok: false, } } - - // Delete existing indices - const doc = await this.find(id) - if (doc) { - const atomic = this.kv.atomic() - - await deleteIndices( - id, - doc.value as KvObject, - atomic, - this, - ) - - await atomic.commit() - } } // If serialized, delete existing segment entries @@ -2284,9 +2601,14 @@ export class Collection< }) } - // Remove id from value if indexed entry + // Remove id from value and return parsed document if indexed entry if (typeof indexedDocId !== "undefined") { - delete (value as any).__id__ + const { __id__, ...val } = value as any + return new Document>(this._model, { + id: docId as ParseId, + value: val as TOutput, + versionstamp, + }) } // Return parsed document diff --git a/src/ext/kv/atomic.ts b/src/ext/kv/atomic.ts new file mode 100644 index 0000000..a2d94d0 --- /dev/null +++ b/src/ext/kv/atomic.ts @@ -0,0 +1,142 @@ +import type { + DenoAtomicCheck, + DenoAtomicOperation, + DenoKvCommitError, + DenoKvCommitResult, + DenoKvEnqueueOptions, + DenoKvSetOptions, + DenoKvStrictKey, +} from "../../types.ts" +import type { MapKv } from "./map_kv.ts" +import { createVersionstamp } from "./utils.ts" + +export class MapKvAtomicOperation implements DenoAtomicOperation { + private kv: MapKv + private checks: (() => boolean)[] + private ops: ((versionstamp: string) => void)[] + + constructor(kv: MapKv) { + this.kv = kv + this.checks = [] + this.ops = [] + } + + set( + key: DenoKvStrictKey, + value: unknown, + options?: DenoKvSetOptions, + ): DenoAtomicOperation { + this.ops.push((versionstamp) => + this.kv._set(key, value, versionstamp, options) + ) + return this + } + + delete(key: DenoKvStrictKey): DenoAtomicOperation { + this.ops.push(() => this.kv.delete(key)) + return this + } + + min(key: DenoKvStrictKey, n: bigint): DenoAtomicOperation { + this.ops.push((versionstamp) => { + const { value } = this.kv.get(key) + if (!value) { + this.kv._set(key, { value: n }, versionstamp) + return + } + + const val = (value as any).value + if (typeof val !== "bigint") { + throw new Error("Min operation can only be performed on KvU64 value") + } + + this.kv._set(key, { + value: n < val ? n : val, + }, versionstamp) + }) + + return this + } + + max(key: DenoKvStrictKey, n: bigint): DenoAtomicOperation { + this.ops.push((versionstamp) => { + const { value } = this.kv.get(key) + if (!value) { + this.kv._set(key, { value: n }, versionstamp) + return + } + + const val = (value as any).value + if (typeof val !== "bigint") { + throw new Error("Max operation can only be performed on KvU64 value") + } + + this.kv._set(key, { + value: n > val ? n : val, + }, versionstamp) + }) + + return this + } + + sum(key: DenoKvStrictKey, n: bigint): DenoAtomicOperation { + this.ops.push((versionstamp) => { + const { value } = this.kv.get(key) + if (!value) { + this.kv._set(key, { value: n }, versionstamp) + return + } + + const val = (value as any).value + if (typeof val !== "bigint") { + throw new Error("Sum operation can only be performed on KvU64 value") + } + + this.kv._set(key, { + value: n + val, + }, versionstamp) + }) + + return this + } + + check(...checks: DenoAtomicCheck[]): DenoAtomicOperation { + checks.forEach(({ key, versionstamp }) => { + this.checks.push(() => { + const entry = this.kv.get(key) + return entry.versionstamp === versionstamp + }) + }) + + return this + } + + enqueue(value: unknown, options?: DenoKvEnqueueOptions): DenoAtomicOperation { + this.ops.push((versionstamp) => { + this.kv._enqueue(value, versionstamp, options) + }) + + return this + } + + commit(): DenoKvCommitError | DenoKvCommitResult { + const passedChecks = this.checks + .map((check) => check()) + .every((check) => check) + + if (!passedChecks) { + return { + ok: false, + } + } + + const versionstamp = createVersionstamp() + + this.ops.forEach((op) => op(versionstamp)) + + return { + ok: true, + versionstamp, + } + } +} diff --git a/src/ext/kv/map_kv.ts b/src/ext/kv/map_kv.ts new file mode 100644 index 0000000..9f8fb55 --- /dev/null +++ b/src/ext/kv/map_kv.ts @@ -0,0 +1,255 @@ +import type { + DenoAtomicOperation, + DenoKv, + DenoKvCommitResult, + DenoKvEnqueueOptions, + DenoKvEntry, + DenoKvEntryMaybe, + DenoKvLaxKey, + DenoKvListIterator, + DenoKvListOptions, + DenoKvListSelector, + DenoKvSetOptions, + DenoKvStrictKey, + DenoKvWatchOptions, +} from "../../types.ts" +import { jsonParse, jsonStringify } from "../../utils.ts" +import { MapKvAtomicOperation } from "./atomic.ts" +import { Watcher } from "./watcher.ts" +import { createVersionstamp, keySort } from "./utils.ts" +import type { BasicMap, MapKvOptions } from "./types.ts" + +/** + * KV instance utilising a `BasicMap` as it's backend. + * + * Uses `new Map()` by default. + * + * @example + * ```ts + * // Initializes a new KV instance wrapping the built-in `Map` + * const kv = new MapKv() + * ``` + * + * @example + * ```ts + * // Initializes a new KV instance utilizing `localStorage` as it's backend + * const map = new StorageAdapter() + * const kv = new MapKv({ map }) + * ``` + * + * @example + * ```ts + * // Initializes a new ephimeral KV instance explicitly utilizing `localStorage` as it's backend + * const map = new StorageAdapter(localStorage) + * const kv = new MapKv({ map, clearOnClose: true }) + * ``` + */ +export class MapKv implements DenoKv { + private map: BasicMap> + private clearOnClose: boolean + private watchers: Watcher[] + private listenHandlers: ((msg: unknown) => unknown)[] + private listener: + | { + promise: Promise + resolve: () => void + } + | undefined + + constructor({ + map = new Map(), + entries, + clearOnClose = false, + }: MapKvOptions = {}) { + this.map = map + this.clearOnClose = clearOnClose + this.watchers = [] + this.listenHandlers = [] + + entries?.forEach(({ key, ...data }) => + this.map.set(jsonStringify(key), data) + ) + } + + close(): void { + this.watchers.forEach((w) => w.cancel()) + this.listener?.resolve() + if (this.clearOnClose) this.map.clear() + } + + delete(key: DenoKvStrictKey) { + this.map.delete(jsonStringify(key)) + this.watchers.forEach((w) => w.update(key)) + } + + get(key: DenoKvStrictKey): DenoKvEntryMaybe { + const data = this.map.get(jsonStringify(key)) ?? { + value: null, + versionstamp: null, + } + + return { + ...data, + key: key as DenoKvLaxKey, + } + } + + getMany(keys: DenoKvStrictKey[]): DenoKvEntryMaybe[] { + return keys.map((key) => this.get(key)) + } + + set( + key: DenoKvStrictKey, + value: unknown, + options?: DenoKvSetOptions, + ): DenoKvCommitResult { + return this._set(key, value, createVersionstamp(), options) + } + + list( + selector: DenoKvListSelector, + options?: DenoKvListOptions, + ): DenoKvListIterator { + let entries = Array.from(this.map.entries()) + const start = (selector as any).start as DenoKvStrictKey | undefined + const end = (selector as any).end as DenoKvStrictKey | undefined + const prefix = (selector as any).prefix as DenoKvStrictKey | undefined + + entries.sort(([k1], [k2]) => { + const key1 = jsonParse(k1) + const key2 = jsonParse(k2) + return keySort(key1, key2) + }) + + if (options?.reverse) { + entries.reverse() + } + + if (prefix && prefix.length > 0) { + entries = entries.filter(([key]) => { + const parsedKey = jsonParse(key) + const keyPrefix = parsedKey.slice(0, prefix.length) + return jsonStringify(keyPrefix) === jsonStringify(prefix) + }) + } + + if (start) { + const index = entries.findIndex( + ([key]) => key === jsonStringify(start), + ) + + if (index) { + entries = entries.slice(index) + } + } + + if (end) { + const index = entries.findIndex( + ([key]) => key === jsonStringify(end), + ) + + if (index) { + entries = entries.slice(0, index) + } + } + + if (options?.cursor) { + const index = entries.findIndex( + ([key]) => key === options.cursor, + ) + + if (index) { + entries = entries.slice(index) + } + } + + const iter = async function* () { + let count = 0 + + for (const [key, entry] of entries) { + if (options?.limit !== undefined && count >= options?.limit) { + return + } + + yield { + key: jsonParse(key) as DenoKvLaxKey, + ...entry, + } + + count++ + } + } + + const cursorEntry = options?.limit ? entries.at(options?.limit) : undefined + const cursor = cursorEntry ? cursorEntry[0] : "" + return Object.assign(iter(), { cursor }) + } + + listenQueue(handler: (value: unknown) => unknown): Promise { + this.listenHandlers.push(handler) + + if (!this.listener) { + this.listener = Promise.withResolvers() + } + + return this.listener.promise + } + + enqueue( + value: unknown, + options?: DenoKvEnqueueOptions, + ): Promise | DenoKvCommitResult { + return this._enqueue(value, createVersionstamp(), options) + } + + watch( + keys: DenoKvStrictKey[], + options?: DenoKvWatchOptions, + ): ReadableStream { + const watcher = new Watcher(this, keys, options) + this.watchers.push(watcher) + return watcher.stream + } + + atomic(): DenoAtomicOperation { + return new MapKvAtomicOperation(this) + } + + _set( + key: DenoKvStrictKey, + value: unknown, + versionstamp: string, + options?: DenoKvSetOptions, + ): DenoKvCommitResult { + this.map.set(jsonStringify(key), { + value, + versionstamp: versionstamp, + }) + + this.watchers.forEach((w) => w.update(key)) + + if (options?.expireIn !== undefined) { + setTimeout(() => this.delete(key), options.expireIn) + } + + return { + ok: true, + versionstamp, + } + } + + _enqueue( + value: unknown, + versionstamp: string, + options?: DenoKvEnqueueOptions, + ): Promise | DenoKvCommitResult { + setTimeout(async () => { + await Promise.all(this.listenHandlers.map((h) => h(value))) + }, options?.delay ?? 0) + + return { + ok: true, + versionstamp, + } + } +} diff --git a/src/ext/kv/mod.ts b/src/ext/kv/mod.ts new file mode 100644 index 0000000..400ca04 --- /dev/null +++ b/src/ext/kv/mod.ts @@ -0,0 +1,55 @@ +/** + * @module # KV + * + * Support for alternative KV backends, such as `Map` and `localStorage`. + * + * Can be used to employ `kvdex` in the browser or other environments where Deno's KV store is not available, + * or to adapt to other database backends. + * + * @example + * ```ts + * import { kvdex } from "@olli/kvdex" + * import { MapKv } from "@olli/kvdex/kv" + * + * // Create a database from a `MapKv` instance, using `Map` as it's backend by default. + * const kv = new MapKv() + * const db = kvdex(kv, {}) + * ``` + * + * @example + * ```ts + * import { kvdex } from "@olli/kvdex" + * import { MapKv } from "@olli/kvdex/kv" + * + * // Create a database from a `MapKv` instance, explicitly using `Map` as it's backend. + * const kv = new MapKv({ map: new Map() }) + * const db = kvdex(kv, {}) + * ``` + * + * @example + * ```ts + * import { kvdex } from "@olli/kvdex" + * import { MapKv, StorageAdapter } from "@olli/kvdex/kv" + * + * // Create a database from a `MapKv` instance, using `localStorage` as it's backend by default. + * const map = new StorageAdapter() + * const kv = new MapKv({ map }) + * const db = kvdex(kv, {}) + * ``` + * + * @example + * ```ts + * import { kvdex } from "@olli/kvdex" + * import { MapKv, StorageAdapter } from "@olli/kvdex/kv" + * + * // Create an ephimeral database from a `MapKv` instance, explicitly using `localStorage` as it's backend. + * const map = new StorageAdapter(localStorage) + * const kv = new MapKv({ map, clearOnClose: true }) + * const db = kvdex(kv, {}) + * ``` + */ + +export { MapKv } from "./map_kv.ts" +export { StorageAdapter } from "./storage_adapter.ts" +export { MapKvAtomicOperation } from "./atomic.ts" +export type * from "./types.ts" diff --git a/src/ext/kv/storage_adapter.ts b/src/ext/kv/storage_adapter.ts new file mode 100644 index 0000000..4afca64 --- /dev/null +++ b/src/ext/kv/storage_adapter.ts @@ -0,0 +1,60 @@ +import { jsonParse, jsonStringify } from "../../utils.ts" +import type { BasicMap } from "./types.ts" + +/** + * BasicMap adapter for Storage. + * + * Enables a Storage object, such as `localStorage`, to be utilized as a basic map. + * + * Wraps `localStorage` by default. + * + * @example + * ```ts + * // Creates a new BasicMap, wrapping `localStorage` + * const map = new StorageAdapter() + * ``` + * + * @example + * ```ts + * // Creates a new BasicMap, explicitly wrapping `localStorage` + * const map = new StorageAdapter(localStorage) + * ``` + */ +export class StorageAdapter implements BasicMap { + private storage: Storage + + constructor(storage: Storage = localStorage) { + this.storage = storage + } + + set(key: K, value: V): void { + this.storage.setItem(jsonStringify(key), jsonStringify(value)) + } + + get(key: K): V | undefined { + const valStr = this.storage.getItem(jsonStringify(key)) + return !valStr ? undefined : jsonParse(valStr) + } + + delete(key: K): void { + this.storage.removeItem(jsonStringify(key)) + } + + *entries(): IterableIterator<[K, V]> { + for (let i = 0; i < this.storage.length; i++) { + const keyStr = this.storage.key(i) + if (!keyStr) return + + const valStr = this.storage.getItem(keyStr) + if (!valStr) return + + const key = jsonParse(keyStr) + const value = jsonParse(valStr) + yield [key, value] + } + } + + clear(): void { + this.storage.clear() + } +} diff --git a/src/ext/kv/types.ts b/src/ext/kv/types.ts new file mode 100644 index 0000000..3e82ec5 --- /dev/null +++ b/src/ext/kv/types.ts @@ -0,0 +1,59 @@ +import type { DenoKvEntry } from "../../types.ts" + +/** Interface for basic map methods */ +export type BasicMap = { + /** + * Set a new key/value entry. + * + * @param key - Key that identifies the entry. + * @param value - Value of the entry. + * @returns void + */ + set(key: K, value: V): void + + /** + * Get a key/value entry from the map. + * + * @param key - Key that identifies the entry. + * @returns The entry value or undefined if it does not exist in the map. + */ + get(key: K): V | undefined + + /** + * Delete a key/value entry from the map. + * + * @param key - Key that identifies the entry. + * @returns void + */ + delete(key: K): void + + /** + * Get an iterator of the key/value entries in the map. + * + * @returns An IterableIterator of [key, value] entries. + */ + entries(): IterableIterator<[K, V]> + + /** Removes all key/value entries from the map. */ + clear(): void +} + +/** Options when constructing a new MapKv instance. */ +export type MapKvOptions = { + /** + * Underlying map used for data storage. + * + * @default new Map() + */ + map?: BasicMap + + /** Initial KV entries. */ + entries?: DenoKvEntry[] + + /** + * Whether the underlying map should be cleared or not when the store is closed. + * + * @default false + */ + clearOnClose?: boolean +} diff --git a/src/ext/kv/utils.ts b/src/ext/kv/utils.ts new file mode 100644 index 0000000..d0883cf --- /dev/null +++ b/src/ext/kv/utils.ts @@ -0,0 +1,145 @@ +import { ulid } from "../../deps.ts" +import type { DenoKvStrictKey, DenoKvStrictKeyPart } from "../../types.ts" + +export function createVersionstamp() { + return ulid() +} + +export function keySort(key1: DenoKvStrictKey, key2: DenoKvStrictKey): number { + for (let i = 0; i < Math.min(key1.length, key2.length); i++) { + const p1 = key1.at(i) + const p2 = key2.at(i) + + if (p1 === undefined) { + return -1 + } + + if (p2 === undefined) { + return 1 + } + + const typeSorted = sortByType(p1, p2) + if (typeSorted !== 0) { + return typeSorted + } + + const valueSorted = sortByValue(p1, p2) + if (valueSorted !== 0) { + return valueSorted + } + } + + if (key1.length < key2.length) { + return -1 + } + + if (key1.length > key2.length) { + return 1 + } + + return 0 +} + +const typeMap = { + object: 0, + string: 1, + number: 2, + bigint: 3, + boolean: 4, + function: 5, + symbol: 5, + undefined: 5, +} + +function sortByType( + part1: DenoKvStrictKeyPart, + part2: DenoKvStrictKeyPart, +): number { + const t1 = typeMap[typeof part1] + const t2 = typeMap[typeof part2] + return t1 - t2 +} + +function sortByValue( + part1: DenoKvStrictKeyPart, + part2: DenoKvStrictKeyPart, +) { + if (typeof part2 !== typeof part2) { + throw Error("Cannot compare values of different type") + } + + switch (typeof part1) { + case "object": { + return sortByUint8Array(part1, part2 as Uint8Array) + } + + case "string": { + return sortByString(part1, part2 as string) + } + + case "number": { + return sortByNumber(part1, part2 as number) + } + + case "bigint": { + return sortByBigint(part1, part2 as bigint) + } + + case "boolean": { + return sortByBoolean(part1, part2 as boolean) + } + + default: { + return 0 + } + } +} + +function sortByUint8Array(u1: Uint8Array, u2: Uint8Array) { + for (let i = 0; i < Math.min(u1.length, u2.length); i++) { + const b1 = u1.at(i) + const b2 = u2.at(i) + + if (b1 === undefined) { + return -1 + } + + if (b2 === undefined) { + return 1 + } + + if (b2 > b1) { + return -1 + } + + if (b2 < b1) { + return 1 + } + } + + if (u1.length < u2.length) { + return -1 + } + + if (u1.length > u2.length) { + return 1 + } + + return 0 +} + +function sortByString(str1: string, str2: string): number { + return str1.localeCompare(str2) +} + +function sortByNumber(n1: number, n2: number): number { + return n1 - n2 +} + +function sortByBigint(n1: bigint, n2: bigint): number { + return n1 < n2 ? -1 : n1 > n2 ? 1 : 0 +} + +function sortByBoolean(b1: boolean, b2: boolean): number { + return Number(b1) - Number(b2) +} diff --git a/src/ext/kv/watcher.ts b/src/ext/kv/watcher.ts new file mode 100644 index 0000000..9bb9f00 --- /dev/null +++ b/src/ext/kv/watcher.ts @@ -0,0 +1,85 @@ +import type { DenoKvWatchOptions } from "../../../mod.ts" +import type { DenoKvEntryMaybe, DenoKvStrictKey } from "../../types.ts" +import { jsonStringify } from "../../utils.ts" +import type { MapKv } from "./map_kv.ts" + +export class Watcher { + private kv: MapKv + private keys: DenoKvStrictKey[] + private options?: DenoKvWatchOptions + private listener: ReturnType> + private previousEntries: DenoKvEntryMaybe[] + readonly stream: ReadableStream + + constructor( + kv: MapKv, + keys: DenoKvStrictKey[], + options?: DenoKvWatchOptions, + ) { + this.kv = kv + this.keys = keys + this.options = options + + const previousEntries = kv.getMany(keys) + this.previousEntries = previousEntries + + this.listener = Promise.withResolvers() + const listener = this.listener + + this.stream = new ReadableStream({ + async start(controller) { + controller.enqueue(previousEntries) + while (true) { + try { + const entries = await listener.promise + controller.enqueue(entries) + } catch (_) { + controller.close() + break + } + } + }, + + cancel() { + listener.reject() + }, + }) + } + + update(key: DenoKvStrictKey) { + const match = this.keys.some((k) => jsonStringify(k) === jsonStringify(key)) + if (!match) return + + const entries = this.kv.getMany(this.keys) + + const previousEntry = this.previousEntries.find((entry) => + jsonStringify(entry.key) === jsonStringify(key) + ) + + const newEntry = entries.find((entry) => + jsonStringify(entry.key) === jsonStringify(key) + ) + + if (!previousEntry || !newEntry) return + + // if ( + // !options?.raw && + // previousEntry.versionstamp === newEntry.versionstamp + // ) return + + this.previousEntries = entries + this.listener.resolve(entries) + + const { promise, resolve, reject } = Promise.withResolvers< + DenoKvEntryMaybe[] + >() + + this.listener.promise = promise + this.listener.resolve = resolve + this.listener.reject = reject + } + + cancel() { + this.listener.reject() + } +} diff --git a/src/ext/migrate/deps.ts b/src/ext/migrate/deps.ts new file mode 100644 index 0000000..bf39c9b --- /dev/null +++ b/src/ext/migrate/deps.ts @@ -0,0 +1 @@ +export { parseArgs } from "jsr:@std/cli@^0.220/parse_args" diff --git a/src/ext/migrate/errors.ts b/src/ext/migrate/errors.ts new file mode 100644 index 0000000..baf6de4 --- /dev/null +++ b/src/ext/migrate/errors.ts @@ -0,0 +1,10 @@ +export class NoKvFoundError extends Error { + name = "NoKvFoundError" + + constructor( + message?: string | undefined, + options?: ErrorOptions | undefined, + ) { + super(message, options) + } +} diff --git a/src/ext/migrate/migrate.ts b/src/ext/migrate/migrate.ts new file mode 100644 index 0000000..846fb21 --- /dev/null +++ b/src/ext/migrate/migrate.ts @@ -0,0 +1,31 @@ +import { KVDEX_KEY_PREFIX } from "../../constants.ts" +import type { MigrateOptions } from "./types.ts" + +/** + * Migrate entries from a source KV instance to a target KV instance. + * + * @example + * ```ts + * import { migrate } from "jsr:@olli/kvdex/ext/migrate" + * + * const source = await Deno.openKv("./source.sqlite3") + * const target = await Deno.openKv("./target.sqlite3") + * + * await migrate({ + * source, + * target, + * }) + * ``` + * + * @param options - Migrate options + */ +export async function migrate({ + source, + target, + all, +}: MigrateOptions): Promise { + const iter = source.list({ prefix: all ? [] : [KVDEX_KEY_PREFIX] }) + for await (const { key, value } of iter) { + await target.set(key, value) + } +} diff --git a/ext/migrate.ts b/src/ext/migrate/mod.ts similarity index 54% rename from ext/migrate.ts rename to src/ext/migrate/mod.ts index 665e5fd..0a6fb09 100644 --- a/ext/migrate.ts +++ b/src/ext/migrate/mod.ts @@ -32,20 +32,17 @@ * ``` */ -import { parseArgs } from "jsr:@std/cli@^0.217/parse_args" -import { KVDEX_KEY_PREFIX } from "../src/constants.ts" +// Imports +import { parseArgs } from "./deps.ts" +import { migrate } from "./migrate.ts" +import { NoKvFoundError } from "./errors.ts" -export class NoKvFoundError extends Error { - name = "NoKvFoundError" - - constructor( - message?: string | undefined, - options?: ErrorOptions | undefined, - ) { - super(message, options) - } -} +// Exports +export { migrate } +export type * from "./types.ts" +export * from "./errors.ts" +// Run migrate if main if (import.meta.main) { const { source, target, all } = parseArgs(Deno.args, { string: ["source", "target"], @@ -73,48 +70,3 @@ if (import.meta.main) { all, }) } - -/** Options for migrating entries from a source KV instance to a target KV instance */ -export type MigrateOptions = { - /** Source KV. */ - source: Deno.Kv - - /** Target KV. */ - target: Deno.Kv - - /** - * Flag indicating whether to migrate all entries or only kvdex specific entries. - * - * @default false - */ - all?: boolean -} - -/** - * Migrate entries from a source KV instance to a target KV instance. - * - * @example - * ```ts - * import { migrate } from "jsr:@olli/kvdex/ext/migrate" - * - * const source = await Deno.openKv("./source.sqlite3") - * const target = await Deno.openKv("./target.sqlite3") - * - * await migrate({ - * source, - * target, - * }) - * ``` - * - * @param options - Migrate options - */ -export async function migrate({ - source, - target, - all, -}: MigrateOptions): Promise { - const iter = source.list({ prefix: all ? [] : [KVDEX_KEY_PREFIX] }) - for await (const { key, value } of iter) { - await target.set(key, value) - } -} diff --git a/src/ext/migrate/types.ts b/src/ext/migrate/types.ts new file mode 100644 index 0000000..8c46997 --- /dev/null +++ b/src/ext/migrate/types.ts @@ -0,0 +1,15 @@ +/** Options for migrating entries from a source KV instance to a target KV instance */ +export type MigrateOptions = { + /** Source KV. */ + source: Deno.Kv + + /** Target KV. */ + target: Deno.Kv + + /** + * Flag indicating whether to migrate all entries or only kvdex specific entries. + * + * @default false + */ + all?: boolean +} diff --git a/src/ext/zod/deps.ts b/src/ext/zod/deps.ts new file mode 100644 index 0000000..490ab1a --- /dev/null +++ b/src/ext/zod/deps.ts @@ -0,0 +1 @@ +export { z } from "npm:zod@^3.22" diff --git a/src/ext/zod/mod.ts b/src/ext/zod/mod.ts new file mode 100644 index 0000000..039afef --- /dev/null +++ b/src/ext/zod/mod.ts @@ -0,0 +1,28 @@ +/** + * @module # Zod + * + * Extended support for Zod. Includes schemas for some of the KV-types. + * + * ## Schemas + * + * The zod extension provides schemas for some of the Kv-types, such as KvId, + * KvValue, KvObject and KvArray. This makes it easier to properly build your + * schemas. + * + * ```ts + * import { z } from "npm:zod" + * import { KvIdSchema } from "jsr:@olli/kvdex/zod" + * + * const UserSchema = z.object({ + * username: z.string(), + * postIds: z.array(KvIdSchema), + * }) + * + * const PostSchema = z.object({ + * text: z.string(), + * userId: KvIdSchema, + * }) + * ``` + */ + +export * from "./schemas.ts" diff --git a/ext/zod.ts b/src/ext/zod/schemas.ts similarity index 64% rename from ext/zod.ts rename to src/ext/zod/schemas.ts index 6021828..8f5ab20 100644 --- a/ext/zod.ts +++ b/src/ext/zod/schemas.ts @@ -1,38 +1,6 @@ -/** - * @module # Zod - * - * Extended support for Zod. Includes schemas for some of the KV-types. - * - * ## Schemas - * - * The zod extension provides schemas for some of the Kv-types, such as KvId, - * KvValue, KvObject and KvArray. This makes it easier to properly build your - * schemas. - * - * ```ts - * import { z } from "npm:zod" - * import { KvIdSchema } from "jsr:@olli/kvdex/zod" - * - * const UserSchema = z.object({ - * username: z.string(), - * postIds: z.array(KvIdSchema), - * }) - * - * const PostSchema = z.object({ - * text: z.string(), - * userId: KvIdSchema, - * }) - * ``` - */ +import { z } from "./deps.ts" +import type { KvArray, KvId, KvObject, KvValue } from "../../types.ts" -import { z } from "npm:zod@^3.22" -import type { KvArray, KvId, KvObject, KvValue } from "../src/types.ts" - -/*******************/ -/* */ -/* SCHEMAS */ -/* */ -/*******************/ const LazyKvValueSchema = z.lazy(() => KvValueSchema) const LazyKvArraySchema = z.lazy(() => KvArraySchema) diff --git a/src/types.ts b/src/types.ts index 3bf5964..df9f5b7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -913,14 +913,19 @@ export type DenoKvListOptions = { } /** Deno [KVListIterator](https://deno.land/api?s=Deno.KvListIterator&unstable=) substitute type */ -export type DenoKvListIterator = AsyncIterableIterator & { - /** - * Cursor of the current position in the iteration. - * This cursor can be used to resume iteration from the current position in the future - * by passing it to any of the list operations (e.g. `getMany()`, `map()`, `forEach()` etc). - */ - cursor: string -} +export type DenoKvListIterator = + & ( + | AsyncIterableIterator + | IterableIterator + ) + & { + /** + * Cursor of the current position in the iteration. + * This cursor can be used to resume iteration from the current position in the future + * by passing it to any of the list operations (e.g. `getMany()`, `map()`, `forEach()` etc). + */ + cursor: string + } /** Deno [AtomicOperation](deno.land/api?s=Deno.AtomicOperation&unstable=) substitute type */ export type DenoAtomicOperation = { diff --git a/tests/collection/watchMany.test.ts b/tests/collection/watchMany.test.ts index 5898864..8ac88b3 100644 --- a/tests/collection/watchMany.test.ts +++ b/tests/collection/watchMany.test.ts @@ -82,8 +82,8 @@ Deno.test("collection - watchMany", async (t) => { await t.step("Should not receive unrelated document updates", async () => { await useDb(async (db) => { const id1 = "id1" - const id2 = "id1" - const id3 = "id1" + const id2 = "id2" + const id3 = "id3" const id4 = "id4" let count = 0 let lastDocs: any[] = [] diff --git a/tests/ext/kv.test.ts b/tests/ext/kv.test.ts new file mode 100644 index 0000000..cce0bdb --- /dev/null +++ b/tests/ext/kv.test.ts @@ -0,0 +1,187 @@ +import { MapKv } from "../../src/ext/kv/map_kv.ts" +import { StorageAdapter } from "../../src/ext/kv/mod.ts" +import { assert, assertEquals } from "../test.deps.ts" +import { sleep } from "../utils.ts" + +async function useStore(fn: (store: StorageAdapter) => unknown) { + const store = new StorageAdapter(localStorage) + await fn(store) + store.clear() +} + +Deno.test("ext - kv", async (t) => { + await t.step("set", async (t) => { + await t.step("Should set new entry", () => { + const kv = new MapKv() + const key = ["test"] + + const cr = kv.set(key, 10) + const entry = kv.get(key) + assert(cr.ok) + assert(entry.value !== null) + assert(entry.versionstamp !== null) + }) + + await t.step("Should remove new entry after expire time", async () => { + const kv = new MapKv() + const key = ["test"] + + const cr = kv.set(key, 10, { expireIn: 100 }) + const entry1 = kv.get(key) + assert(cr.ok) + assert(entry1.value !== null) + assert(entry1.versionstamp !== null) + + await sleep(500) + + const entry2 = kv.get(key) + assert(entry2.value === null) + assert(entry2.versionstamp === null) + }) + }) + + await t.step("get", async (t) => { + await t.step("Should successfully get entry by key", () => { + const kv = new MapKv() + const key = ["test"] + const val = 10 + + const cr = kv.set(key, val) + const entry = kv.get(key) + assert(cr.ok) + assert(entry.value === val) + assert(entry.versionstamp !== null) + }) + }) + + await t.step("getMany", async (t) => { + await t.step("Should successfully get entries by keys", () => { + const kv = new MapKv() + const entries = [ + [["test", 1], 10], + [["test", 2], 20], + [["test", 3], 30], + ] + + const crs = entries.map(([key, val]) => kv.set(key as any, val)) + assert(crs.every((cr) => cr.ok)) + + const getEntries = kv.getMany(entries.map(([k]) => k as any)) + + getEntries.forEach((entry) => { + assert(entries.some(([_, val]) => val === entry.value)) + }) + }) + }) + + await t.step("delete", async (t) => { + await t.step("Should successfully delete entry by key", () => { + const kv = new MapKv() + const key = ["test"] + + const cr = kv.set(key, 10) + const entry1 = kv.get(key) + assert(cr.ok) + assert(entry1.value !== null) + assert(entry1.versionstamp !== null) + + kv.delete(key) + + const entry2 = kv.get(key) + assert(entry2.value === null) + assert(entry2.versionstamp === null) + }) + }) + + await t.step("list", async (t) => { + await t.step("Should list all entries in ascending order", async () => { + const kv = new MapKv() + const entries = [ + [["test", 1], 10], + [["test", 2], 20], + [["test", 3], 30], + ] + + const crs = entries.map(([key, val]) => kv.set(key as any, val)) + assert(crs.every((cr) => cr.ok)) + + const iter = kv.list({ prefix: [] }) + const listEntries = await Array.fromAsync(iter) + + listEntries.forEach((entry, i) => { + assert(entry.value === entries[i][1]) + }) + }) + }) + + await t.step("storage_adapter (localStorage)", async (t) => { + await t.step("Should set and get new entry", async () => { + await useStore((store) => { + const key = "key" + const val = 10 + store.set(key, val) + const item = store.get(key) + assertEquals(val, item) + }) + }) + + await t.step("Should get all entries", async () => { + await useStore((store) => { + const entries = [ + ["1", 10], + ["2", 20], + ["3", 30], + ["4", 40], + ["5", 50], + ] as const + + for (const [key, val] of entries) { + store.set(key, val) + } + + const storeEntries = Array.from(store.entries()) + assertEquals(entries.length, storeEntries.length) + + for (const [key, val] of storeEntries) { + assert(entries.some(([k, v]) => k === key && v === val)) + } + }) + }) + + await t.step("Should delete entry by key", async () => { + await useStore((store) => { + const key = "key" + const val = 10 + store.set(key, val) + const item1 = store.get(key) + assertEquals(item1, val) + store.delete(key) + const item2 = store.get(key) + assertEquals(item2, undefined) + }) + }) + + await t.step("Should delete entry by key", async () => { + await useStore((store) => { + const entries = [ + ["1", 10], + ["2", 20], + ["3", 30], + ["4", 40], + ["5", 50], + ] as const + + for (const [key, val] of entries) { + store.set(key, val) + } + + const storeEntries1 = Array.from(store.entries()) + assertEquals(storeEntries1.length, entries.length) + + store.clear() + const storeEntries2 = Array.from(store.entries()) + assertEquals(storeEntries2.length, 0) + }) + }) + }) +}) diff --git a/tests/ext/migrate.test.ts b/tests/ext/migrate.test.ts index 7d716fc..e36da16 100644 --- a/tests/ext/migrate.test.ts +++ b/tests/ext/migrate.test.ts @@ -1,4 +1,4 @@ -import { migrate } from "../../ext/migrate.ts" +import { migrate } from "../../src/ext/migrate/mod.ts" import { collection } from "../../src/collection.ts" import { kvdex } from "../../src/kvdex.ts" import { model } from "../../src/model.ts" diff --git a/tests/ext/zod.test.ts b/tests/ext/zod.test.ts index 14c53a5..910e1de 100644 --- a/tests/ext/zod.test.ts +++ b/tests/ext/zod.test.ts @@ -4,7 +4,7 @@ import { KvIdSchema, KvObjectSchema, KvValueSchema, -} from "../../ext/zod.ts" +} from "../../src/ext/zod/mod.ts" import { collection, kvdex } from "../../mod.ts" import { useKv } from "../utils.ts" import { VALUES } from "../values.ts" diff --git a/tests/indexable_collection/countBySecondaryOrder.test.ts b/tests/indexable_collection/countBySecondaryOrder.test.ts new file mode 100644 index 0000000..ca988ed --- /dev/null +++ b/tests/indexable_collection/countBySecondaryOrder.test.ts @@ -0,0 +1,43 @@ +import { assert } from "../test.deps.ts" +import { mockUser1, mockUser2, mockUsersWithAlteredAge } from "../mocks.ts" +import { useDb } from "../utils.ts" + +Deno.test("indexable_collection - countBySecondaryOrder", async (t) => { + await t.step( + "Should correctly count total number of documents in the collection by secondary order", + async () => { + await useDb(async (db) => { + const count1 = await db.i_users.countBySecondaryOrder( + "age", + ) + assert(count1 === 0) + + const cr = await db.i_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const count2 = await db.i_users.countBySecondaryOrder( + "age", + { + limit: 1, + filter: (doc) => doc.value.age < mockUser1.age, + }, + ) + + assert(count2 === 1) + + const count3 = await db.i_users.countBySecondaryOrder( + "age", + { + limit: 2, + filter: (doc) => doc.value.age < mockUser2.age, + }, + ) + + assert(count3 === 2) + + const count4 = await db.i_users.countBySecondaryOrder("age") + assert(count4 === 3) + }) + }, + ) +}) diff --git a/tests/indexable_collection/deleteManyBySecondaryOrder.test.ts b/tests/indexable_collection/deleteManyBySecondaryOrder.test.ts new file mode 100644 index 0000000..b844851 --- /dev/null +++ b/tests/indexable_collection/deleteManyBySecondaryOrder.test.ts @@ -0,0 +1,28 @@ +import { assert, assertEquals } from "../test.deps.ts" +import { mockUser2, mockUsersWithAlteredAge } from "../mocks.ts" +import { useDb } from "../utils.ts" + +Deno.test("indexable_collection - deleteManyBySecondaryOrder", async (t) => { + await t.step( + "Should delete documents and indices from the collection by secondary order", + async () => { + await useDb(async (db) => { + const cr = await db.i_users.addMany(mockUsersWithAlteredAge) + const count1 = await db.i_users.count() + assert(cr.ok) + assertEquals(count1, mockUsersWithAlteredAge.length) + + await db.i_users.deleteManyBySecondaryOrder("age", { + limit: mockUsersWithAlteredAge.length - 1, + }) + + const count2 = await db.i_users.count() + const doc = await db.i_users.getOne() + + assertEquals(count2, 1) + assertEquals(doc?.value.username, mockUser2.username) + assertEquals(doc?.value.address, mockUser2.address) + }) + }, + ) +}) diff --git a/tests/indexable_collection/forEachBySecondaryOrder.test.ts b/tests/indexable_collection/forEachBySecondaryOrder.test.ts new file mode 100644 index 0000000..e6ada9b --- /dev/null +++ b/tests/indexable_collection/forEachBySecondaryOrder.test.ts @@ -0,0 +1,32 @@ +import type { Document } from "../../mod.ts" +import { assert } from "../test.deps.ts" +import { + mockUser1, + mockUser2, + mockUser3, + mockUsersWithAlteredAge, +} from "../mocks.ts" +import type { User } from "../models.ts" +import { useDb } from "../utils.ts" + +Deno.test("indexable_collection - forEachBySecondaryOrder", async (t) => { + await t.step( + "Should run callback function for each document in the collection by secondary order", + async () => { + await useDb(async (db) => { + const cr = await db.i_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const docs: Document[] = [] + await db.i_users.forEachBySecondaryOrder( + "age", + (doc) => docs.push(doc), + ) + + assert(docs[0].value.username === mockUser3.username) + assert(docs[1].value.username === mockUser1.username) + assert(docs[2].value.username === mockUser2.username) + }) + }, + ) +}) diff --git a/tests/indexable_collection/getManyBySecondaryOrder.test.ts b/tests/indexable_collection/getManyBySecondaryOrder.test.ts new file mode 100644 index 0000000..37042f4 --- /dev/null +++ b/tests/indexable_collection/getManyBySecondaryOrder.test.ts @@ -0,0 +1,23 @@ +import { + mockUser1, + mockUser2, + mockUser3, + mockUsersWithAlteredAge, +} from "../mocks.ts" +import { assert } from "../test.deps.ts" +import { useDb } from "../utils.ts" + +Deno.test("indexable_collection - getManyBySecondaryOrder", async (t) => { + await t.step("Should get all documents by secondary order", async () => { + await useDb(async (db) => { + const cr = await db.i_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const { result } = await db.i_users.getManyBySecondaryOrder("age") + assert(result.length === mockUsersWithAlteredAge.length) + assert(result[0].value.username === mockUser3.username) + assert(result[1].value.username === mockUser1.username) + assert(result[2].value.username === mockUser2.username) + }) + }) +}) diff --git a/tests/indexable_collection/getOneBySecondaryOrder.test.ts b/tests/indexable_collection/getOneBySecondaryOrder.test.ts new file mode 100644 index 0000000..eaba000 --- /dev/null +++ b/tests/indexable_collection/getOneBySecondaryOrder.test.ts @@ -0,0 +1,17 @@ +import { assert } from "../test.deps.ts" +import { useDb } from "../utils.ts" +import { mockUser3, mockUsersWithAlteredAge } from "../mocks.ts" + +Deno.test("indexable_collection - getOneBySecondaryOrder", async (t) => { + await t.step("Should get only one document by secondary order", async () => { + await useDb(async (db) => { + const cr = await db.i_users.addMany(mockUsersWithAlteredAge) + + assert(cr.ok) + + const doc = await db.i_users.getOneBySecondaryOrder("age") + assert(doc !== null) + assert(doc.value.username === mockUser3.username) + }) + }) +}) diff --git a/tests/indexable_collection/mapBySecondaryOrder.test.ts b/tests/indexable_collection/mapBySecondaryOrder.test.ts new file mode 100644 index 0000000..f2227f6 --- /dev/null +++ b/tests/indexable_collection/mapBySecondaryOrder.test.ts @@ -0,0 +1,29 @@ +import { assert } from "../test.deps.ts" +import { + mockUser1, + mockUser2, + mockUser3, + mockUsersWithAlteredAge, +} from "../mocks.ts" +import { useDb } from "../utils.ts" + +Deno.test("indexable_collection - mapBySecondaryOrder", async (t) => { + await t.step( + "Should run callback mapper function for each document in the collection by secondary order", + async () => { + await useDb(async (db) => { + const cr = await db.i_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const { result } = await db.i_users.mapBySecondaryOrder( + "age", + (doc) => doc.value.username, + ) + + assert(result[0] === mockUser3.username) + assert(result[1] === mockUser1.username) + assert(result[2] === mockUser2.username) + }) + }, + ) +}) diff --git a/tests/indexable_collection/update.test.ts b/tests/indexable_collection/update.test.ts index f84d9df..b0c42ca 100644 --- a/tests/indexable_collection/update.test.ts +++ b/tests/indexable_collection/update.test.ts @@ -3,6 +3,7 @@ import { assert } from "../test.deps.ts" import { mockUser1, mockUser2, mockUserInvalid } from "../mocks.ts" import type { User } from "../models.ts" import { useDb } from "../utils.ts" +import { mockUser3 } from "../mocks.ts" Deno.test("indexable_collection - update", async (t) => { await t.step( @@ -165,6 +166,38 @@ Deno.test("indexable_collection - update", async (t) => { }, ) + await t.step( + "Should not update document or delete indexed entries upon index collision", + async () => { + await useDb(async (db) => { + const id1 = "id1" + const id2 = "id2" + + const cr1 = await db.i_users.set(id1, mockUser1) + const cr2 = await db.i_users.set(id2, mockUser2) + + assert(cr1.ok) + assert(cr2.ok) + + const update = await db.i_users.update(id2, { + ...mockUser3, + username: mockUser2.username, + }) + + assert(!update.ok) + + const doc = await db.i_users.find(id2) + const docByPrimaryIndex = await db.i_users.findByPrimaryIndex( + "username", + mockUser2.username, + ) + + assert(doc?.value.username === mockUser2.username) + assert(docByPrimaryIndex?.value.username === mockUser2.username) + }) + }, + ) + await t.step("Should successfully parse and update document", async () => { await useDb(async (db) => { let assertion = true diff --git a/tests/indexable_collection/updateManyBySecondaryOrder.test.ts b/tests/indexable_collection/updateManyBySecondaryOrder.test.ts new file mode 100644 index 0000000..f4a3f32 --- /dev/null +++ b/tests/indexable_collection/updateManyBySecondaryOrder.test.ts @@ -0,0 +1,218 @@ +import { assert, assertEquals } from "../test.deps.ts" +import { + mockUser1, + mockUser2, + mockUserInvalid, + mockUsersWithAlteredAge, +} from "../mocks.ts" +import { generateUsers, useDb } from "../utils.ts" +import type { User } from "../models.ts" + +Deno.test.ignore( + "indexable_collection - updateManyBySecondaryOrder", + async (t) => { + await t.step( + "Should update documents of KvObject type using shallow merge by secondary order", + async () => { + await useDb(async (db) => { + const cr = await db.i_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const docs = await db.i_users.getMany() + const ids = docs.result.map((doc) => doc.id) + const versionstamps = docs.result.map((doc) => doc.versionstamp) + + const updateData = { + address: { + country: "Ireland", + city: "Dublin", + houseNr: null, + }, + } + + const { result } = await db.i_users.updateManyBySecondaryOrder( + "age", + updateData, + { + limit: 2, + strategy: "merge-shallow", + }, + ) + + assert( + result.every((cr) => + cr.ok && ids.includes(cr.id) && + !versionstamps.includes(cr.versionstamp) + ), + ) + + await db.i_users.forEachBySecondaryOrder("age", (doc) => { + assert(doc.value.address.country === updateData.address.country) + assert(doc.value.address.city === updateData.address.city) + assert(doc.value.address.houseNr === updateData.address.houseNr) + assert(typeof doc.value.address.street === "undefined") + }, { + limit: 2, + }) + + const last = await db.i_users.getOneBySecondaryOrder("age", { + reverse: true, + }) + + assert(last?.value.username === mockUser2.username) + assert(last.value.address.country === mockUser2.address.country) + }) + }, + ) + + await t.step( + "Should update documents of KvObject type using deep merge", + async () => { + await useDb(async (db) => { + const cr = await db.i_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const docs = await db.i_users.getMany() + const ids = docs.result.map((doc) => doc.id) + const versionstamps = docs.result.map((doc) => doc.versionstamp) + + const updateData = { + address: { + country: "Ireland", + city: "Dublin", + houseNr: null, + }, + } + + const { result } = await db.i_users.updateManyBySecondaryOrder( + "age", + updateData, + { + limit: 2, + strategy: "merge", + }, + ) + + assert( + result.every((cr) => + cr.ok && ids.includes(cr.id) && + !versionstamps.includes(cr.versionstamp) + ), + ) + + await db.i_users.forEachBySecondaryOrder("age", (doc) => { + assert(doc.value.address.country === updateData.address.country) + assert(doc.value.address.city === updateData.address.city) + assert(doc.value.address.houseNr === updateData.address.houseNr) + assert(doc.value.address.street !== undefined) + }, { limit: 2 }) + + const last = await db.i_users.getOneBySecondaryOrder("age", { + reverse: true, + }) + + assert(last?.value.username === mockUser2.username) + assert(last.value.address.country === mockUser2.address.country) + }) + }, + ) + + await t.step( + "Should only update one document of type KvObject using replace (primary index collision)", + async () => { + await useDb(async (db) => { + const cr = await db.i_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const docs = await db.i_users.getMany() + const ids = docs.result.map((doc) => doc.id) + const versionstamps = docs.result.map((doc) => doc.versionstamp) + + const updateData: User = { + username: "test", + age: 10, + address: { + country: "Norway", + city: "Trondheim", + houseNr: 10, + }, + } + + const { result: crs } = await db.i_users.updateManyBySecondaryOrder( + "age", + updateData, + { + strategy: "replace", + }, + ) + + assert( + crs.some((cr) => + cr.ok && ids.includes(cr.id) && + !versionstamps.includes(cr.versionstamp) + ), + ) + + assert( + crs.some((cr) => !cr.ok), + ) + + const { result } = await db.i_users.mapBySecondaryOrder( + "age", + (doc) => doc.value, + ) + + assertEquals(result[0].username, updateData.username) + assertEquals(result[0].address.country, updateData.address.country) + assertEquals(result[0].address.city, updateData.address.city) + assertEquals(result[0].address.houseNr, updateData.address.houseNr) + assertEquals(result[0].address.street, updateData.address.street) + + assertEquals(result[1].username, mockUser1.username) + assertEquals(result[1].address.country, mockUser1.address.country) + assertEquals(result[1].address.city, mockUser1.address.city) + assertEquals(result[1].address.houseNr, mockUser1.address.houseNr) + assertEquals(result[1].address.street, mockUser1.address.street) + + assertEquals(result[2].username, mockUser2.username) + assertEquals(result[2].address.country, mockUser2.address.country) + assertEquals(result[2].address.city, mockUser2.address.city) + assertEquals(result[2].address.houseNr, mockUser2.address.houseNr) + assertEquals(result[2].address.street, mockUser2.address.street) + }) + }, + ) + + await t.step("Should successfully parse and update", async () => { + await useDb(async (db) => { + const users = generateUsers(10) + let assertion = true + + const cr = await db.zi_users.addMany(users) + assert(cr.ok) + + await db.zi_users.updateManyBySecondaryOrder("age", mockUser1) + .catch(() => assertion = false) + + assert(assertion) + }) + }) + + await t.step("Should fail to parse and update document", async () => { + await useDb(async (db) => { + const users = generateUsers(10) + let assertion = false + + const cr = await db.zi_users.addMany(users) + assert(cr.ok) + + await db.zi_users.updateManyBySecondaryOrder( + "age", + mockUserInvalid, + ).catch(() => assertion = true) + + assert(assertion) + }) + }) + }, +) diff --git a/tests/indexable_collection/updateOneBySecondaryOrder.test.ts b/tests/indexable_collection/updateOneBySecondaryOrder.test.ts new file mode 100644 index 0000000..80450e0 --- /dev/null +++ b/tests/indexable_collection/updateOneBySecondaryOrder.test.ts @@ -0,0 +1,205 @@ +import { assert } from "../test.deps.ts" +import { + mockUser1, + mockUser2, + mockUser3, + mockUserInvalid, + mockUsersWithAlteredAge, +} from "../mocks.ts" +import { useDb } from "../utils.ts" +import type { User } from "../models.ts" + +Deno.test("indexable_collection - updateOneBySecondaryOrder", async (t) => { + await t.step( + "Should update only one document of KvObject type using shallow merge", + async () => { + await useDb(async (db) => { + const cr = await db.i_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const updateData = { + address: { + country: "Ireland", + city: "Dublin", + houseNr: null, + }, + } + + const updateCr = await db.i_users.updateOneBySecondaryOrder( + "age", + updateData, + { + strategy: "merge-shallow", + }, + ) + + assert(updateCr.ok) + + const { result } = await db.i_users.mapBySecondaryOrder( + "age", + (doc) => doc.value, + ) + + assert(result[0].address.country === updateData.address.country) + assert(result[0].address.city === updateData.address.city) + assert(result[0].address.houseNr === updateData.address.houseNr) + assert(result[0].address.street === undefined) + + assert(result[1].address.country === mockUser1.address.country) + assert(result[1].address.city === mockUser1.address.city) + assert(result[1].address.houseNr === mockUser1.address.houseNr) + assert(result[1].address.street === mockUser1.address.street) + + assert(result[2].address.country === mockUser2.address.country) + assert(result[2].address.city === mockUser2.address.city) + assert(result[2].address.houseNr === mockUser2.address.houseNr) + assert(result[2].address.street === mockUser2.address.street) + }) + }, + ) + + await t.step( + "Should update only one document of KvObject type using deep merge", + async () => { + await useDb(async (db) => { + const cr = await db.i_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const updateData = { + address: { + country: "Ireland", + city: "Dublin", + houseNr: null, + }, + } + + const updateCr = await db.i_users.updateOneBySecondaryOrder( + "age", + updateData, + { + offset: 1, + strategy: "merge", + }, + ) + + assert(updateCr.ok) + + const { result } = await db.i_users.mapBySecondaryOrder( + "age", + (doc) => doc.value, + ) + + assert(result[1].address.country === updateData.address.country) + assert(result[1].address.city === updateData.address.city) + assert(result[1].address.houseNr === updateData.address.houseNr) + assert(result[1].address.street === mockUser1.address.street) + + assert(result[0].address.country === mockUser3.address.country) + assert(result[0].address.city === mockUser3.address.city) + assert(result[0].address.houseNr === mockUser3.address.houseNr) + assert(result[0].address.street === mockUser3.address.street) + + assert(result[2].address.country === mockUser2.address.country) + assert(result[2].address.city === mockUser2.address.city) + assert(result[2].address.houseNr === mockUser2.address.houseNr) + assert(result[2].address.street === mockUser2.address.street) + }) + }, + ) + + await t.step( + "Should update only one document of KvObject type using replace", + async () => { + await useDb(async (db) => { + const cr = await db.i_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const updateData: User = { + username: "test", + age: 10, + address: { + country: "Switzerland", + city: "Bern", + houseNr: null, + }, + } + + const updateCr = await db.i_users.updateOneBySecondaryOrder( + "age", + updateData, + { + strategy: "replace", + }, + ) + + assert(updateCr.ok) + + const { result } = await db.i_users.mapBySecondaryOrder( + "age", + (doc) => doc.value, + ) + + assert(result[0].username === updateData.username) + assert(result[0].age === updateData.age) + assert(result[0].address.country === updateData.address.country) + assert(result[0].address.city === updateData.address.city) + assert(result[0].address.houseNr === updateData.address.houseNr) + assert(result[0].address.street === undefined) + + assert(result[1].username === mockUser1.username) + assert(result[1].address.country === mockUser1.address.country) + assert(result[1].address.city === mockUser1.address.city) + assert(result[1].address.houseNr === mockUser1.address.houseNr) + assert(result[1].address.street === mockUser1.address.street) + + assert(result[2].username === mockUser2.username) + assert(result[2].address.country === mockUser2.address.country) + assert(result[2].address.city === mockUser2.address.city) + assert(result[2].address.houseNr === mockUser2.address.houseNr) + assert(result[2].address.street === mockUser2.address.street) + }) + }, + ) + + await t.step("Should successfully parse and update", async () => { + await useDb(async (db) => { + let assertion = true + + const cr = await db.zi_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const updateData: User = { + username: "test", + age: 10, + address: { + country: "Switzerland", + city: "Bern", + houseNr: null, + }, + } + + await db.zi_users.updateOneBySecondaryOrder( + "age", + updateData, + ).catch(() => assertion = false) + + assert(assertion) + }) + }) + + await t.step("Should fail to parse and update document", async () => { + await useDb(async (db) => { + let assertion = false + + const cr = await db.zi_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + await db.zi_users.updateOneBySecondaryOrder( + "age", + mockUserInvalid, + ).catch(() => assertion = true) + + assert(assertion) + }) + }) +}) diff --git a/tests/mocks.ts b/tests/mocks.ts index fd7606a..12972a5 100644 --- a/tests/mocks.ts +++ b/tests/mocks.ts @@ -40,3 +40,18 @@ export const mockUserInvalid = { houseNr: "420", }, } as unknown as User + +export const mockUsersWithAlteredAge: User[] = [ + { + ...mockUser1, + age: 50, + }, + { + ...mockUser2, + age: 80, + }, + { + ...mockUser3, + age: 20, + }, +] diff --git a/tests/serialized_indexable_collection/countBySecondaryOrder.test.ts b/tests/serialized_indexable_collection/countBySecondaryOrder.test.ts new file mode 100644 index 0000000..9676c87 --- /dev/null +++ b/tests/serialized_indexable_collection/countBySecondaryOrder.test.ts @@ -0,0 +1,43 @@ +import { assert } from "../test.deps.ts" +import { mockUser1, mockUser2, mockUsersWithAlteredAge } from "../mocks.ts" +import { useDb } from "../utils.ts" + +Deno.test("serialized_indexable_collection - countBySecondaryOrder", async (t) => { + await t.step( + "Should correctly count total number of documents in the collection by secondary order", + async () => { + await useDb(async (db) => { + const count1 = await db.is_users.countBySecondaryOrder( + "age", + ) + assert(count1 === 0) + + const cr = await db.is_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const count2 = await db.is_users.countBySecondaryOrder( + "age", + { + limit: 1, + filter: (doc) => doc.value.age < mockUser1.age, + }, + ) + + assert(count2 === 1) + + const count3 = await db.is_users.countBySecondaryOrder( + "age", + { + limit: 2, + filter: (doc) => doc.value.age < mockUser2.age, + }, + ) + + assert(count3 === 2) + + const count4 = await db.is_users.countBySecondaryOrder("age") + assert(count4 === 3) + }) + }, + ) +}) diff --git a/tests/serialized_indexable_collection/deleteManyBySecondaryOrder.test.ts b/tests/serialized_indexable_collection/deleteManyBySecondaryOrder.test.ts new file mode 100644 index 0000000..47d2e83 --- /dev/null +++ b/tests/serialized_indexable_collection/deleteManyBySecondaryOrder.test.ts @@ -0,0 +1,28 @@ +import { assert, assertEquals } from "../test.deps.ts" +import { mockUser2, mockUsersWithAlteredAge } from "../mocks.ts" +import { useDb } from "../utils.ts" + +Deno.test("serialized_indexable_collection - deleteManyBySecondaryOrder", async (t) => { + await t.step( + "Should delete documents and indices from the collection by secondary order", + async () => { + await useDb(async (db) => { + const cr = await db.is_users.addMany(mockUsersWithAlteredAge) + const count1 = await db.is_users.count() + assert(cr.ok) + assertEquals(count1, mockUsersWithAlteredAge.length) + + await db.is_users.deleteManyBySecondaryOrder("age", { + limit: mockUsersWithAlteredAge.length - 1, + }) + + const count2 = await db.is_users.count() + const doc = await db.is_users.getOne() + + assertEquals(count2, 1) + assertEquals(doc?.value.username, mockUser2.username) + assertEquals(doc?.value.address, mockUser2.address) + }) + }, + ) +}) diff --git a/tests/serialized_indexable_collection/forEachBySecondaryOrder.test.ts b/tests/serialized_indexable_collection/forEachBySecondaryOrder.test.ts new file mode 100644 index 0000000..3fdb6ad --- /dev/null +++ b/tests/serialized_indexable_collection/forEachBySecondaryOrder.test.ts @@ -0,0 +1,32 @@ +import type { Document } from "../../mod.ts" +import { assert } from "../test.deps.ts" +import { + mockUser1, + mockUser2, + mockUser3, + mockUsersWithAlteredAge, +} from "../mocks.ts" +import type { User } from "../models.ts" +import { useDb } from "../utils.ts" + +Deno.test("serialized_indexable_collection - forEachBySecondaryOrder", async (t) => { + await t.step( + "Should run callback function for each document in the collection by secondary order", + async () => { + await useDb(async (db) => { + const cr = await db.is_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const docs: Document[] = [] + await db.is_users.forEachBySecondaryOrder( + "age", + (doc) => docs.push(doc), + ) + + assert(docs[0].value.username === mockUser3.username) + assert(docs[1].value.username === mockUser1.username) + assert(docs[2].value.username === mockUser2.username) + }) + }, + ) +}) diff --git a/tests/serialized_indexable_collection/getManyBySecondaryOrder.test.ts b/tests/serialized_indexable_collection/getManyBySecondaryOrder.test.ts new file mode 100644 index 0000000..69fcc48 --- /dev/null +++ b/tests/serialized_indexable_collection/getManyBySecondaryOrder.test.ts @@ -0,0 +1,23 @@ +import { + mockUser1, + mockUser2, + mockUser3, + mockUsersWithAlteredAge, +} from "../mocks.ts" +import { assert } from "../test.deps.ts" +import { useDb } from "../utils.ts" + +Deno.test("serialized_indexable_collection - getManyBySecondaryOrder", async (t) => { + await t.step("Should get all documents by secondary order", async () => { + await useDb(async (db) => { + const cr = await db.is_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const { result } = await db.is_users.getManyBySecondaryOrder("age") + assert(result.length === mockUsersWithAlteredAge.length) + assert(result[0].value.username === mockUser3.username) + assert(result[1].value.username === mockUser1.username) + assert(result[2].value.username === mockUser2.username) + }) + }) +}) diff --git a/tests/serialized_indexable_collection/getOneBySecondaryOrder.test.ts b/tests/serialized_indexable_collection/getOneBySecondaryOrder.test.ts new file mode 100644 index 0000000..b97ab98 --- /dev/null +++ b/tests/serialized_indexable_collection/getOneBySecondaryOrder.test.ts @@ -0,0 +1,17 @@ +import { assert } from "../test.deps.ts" +import { useDb } from "../utils.ts" +import { mockUser3, mockUsersWithAlteredAge } from "../mocks.ts" + +Deno.test("serialized_indexable_collection - getOneBySecondaryOrder", async (t) => { + await t.step("Should get only one document by secondary order", async () => { + await useDb(async (db) => { + const cr = await db.is_users.addMany(mockUsersWithAlteredAge) + + assert(cr.ok) + + const doc = await db.is_users.getOneBySecondaryOrder("age") + assert(doc !== null) + assert(doc.value.username === mockUser3.username) + }) + }) +}) diff --git a/tests/serialized_indexable_collection/mapBySecondaryOrder.test.ts b/tests/serialized_indexable_collection/mapBySecondaryOrder.test.ts new file mode 100644 index 0000000..6d661aa --- /dev/null +++ b/tests/serialized_indexable_collection/mapBySecondaryOrder.test.ts @@ -0,0 +1,29 @@ +import { assert } from "../test.deps.ts" +import { + mockUser1, + mockUser2, + mockUser3, + mockUsersWithAlteredAge, +} from "../mocks.ts" +import { useDb } from "../utils.ts" + +Deno.test("serialized_indexable_collection - mapBySecondaryOrder", async (t) => { + await t.step( + "Should run callback mapper function for each document in the collection by secondary order", + async () => { + await useDb(async (db) => { + const cr = await db.is_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const { result } = await db.is_users.mapBySecondaryOrder( + "age", + (doc) => doc.value.username, + ) + + assert(result[0] === mockUser3.username) + assert(result[1] === mockUser1.username) + assert(result[2] === mockUser2.username) + }) + }, + ) +}) diff --git a/tests/serialized_indexable_collection/update.test.ts b/tests/serialized_indexable_collection/update.test.ts index f804de1..57e83e0 100644 --- a/tests/serialized_indexable_collection/update.test.ts +++ b/tests/serialized_indexable_collection/update.test.ts @@ -1,6 +1,6 @@ import type { Document } from "../../mod.ts" import { assert } from "../test.deps.ts" -import { mockUser1, mockUser2, mockUserInvalid } from "../mocks.ts" +import { mockUser1, mockUser2, mockUser3, mockUserInvalid } from "../mocks.ts" import type { User } from "../models.ts" import { useDb } from "../utils.ts" @@ -165,6 +165,38 @@ Deno.test("serialized_indexable_collection - update", async (t) => { }, ) + await t.step( + "Should not update document or delete indexed entries upon index collision", + async () => { + await useDb(async (db) => { + const id1 = "id1" + const id2 = "id2" + + const cr1 = await db.is_users.set(id1, mockUser1) + const cr2 = await db.is_users.set(id2, mockUser2) + + assert(cr1.ok) + assert(cr2.ok) + + const update = await db.is_users.update(id2, { + ...mockUser3, + username: mockUser2.username, + }) + + assert(!update.ok) + + const doc = await db.is_users.find(id2) + const docByPrimaryIndex = await db.is_users.findByPrimaryIndex( + "username", + mockUser2.username, + ) + + assert(doc?.value.username === mockUser2.username) + assert(docByPrimaryIndex?.value.username === mockUser2.username) + }) + }, + ) + await t.step("Should successfully parse and update document", async () => { await useDb(async (db) => { let assertion = true diff --git a/tests/serialized_indexable_collection/updateManyBySecondaryOrder.test.ts b/tests/serialized_indexable_collection/updateManyBySecondaryOrder.test.ts new file mode 100644 index 0000000..c6c0e68 --- /dev/null +++ b/tests/serialized_indexable_collection/updateManyBySecondaryOrder.test.ts @@ -0,0 +1,218 @@ +import { assert, assertEquals } from "../test.deps.ts" +import { + mockUser1, + mockUser2, + mockUserInvalid, + mockUsersWithAlteredAge, +} from "../mocks.ts" +import { generateUsers, useDb } from "../utils.ts" +import type { User } from "../models.ts" + +Deno.test.ignore( + "serialized_indexable_collection - updateManyBySecondaryOrder", + async (t) => { + await t.step( + "Should update documents of KvObject type using shallow merge by secondary order", + async () => { + await useDb(async (db) => { + const cr = await db.is_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const docs = await db.is_users.getMany() + const ids = docs.result.map((doc) => doc.id) + const versionstamps = docs.result.map((doc) => doc.versionstamp) + + const updateData = { + address: { + country: "Ireland", + city: "Dublin", + houseNr: null, + }, + } + + const { result } = await db.is_users.updateManyBySecondaryOrder( + "age", + updateData, + { + limit: 2, + strategy: "merge-shallow", + }, + ) + + assert( + result.every((cr) => + cr.ok && ids.includes(cr.id) && + !versionstamps.includes(cr.versionstamp) + ), + ) + + await db.is_users.forEachBySecondaryOrder("age", (doc) => { + assert(doc.value.address.country === updateData.address.country) + assert(doc.value.address.city === updateData.address.city) + assert(doc.value.address.houseNr === updateData.address.houseNr) + assert(typeof doc.value.address.street === "undefined") + }, { + limit: 2, + }) + + const last = await db.is_users.getOneBySecondaryOrder("age", { + reverse: true, + }) + + assert(last?.value.username === mockUser2.username) + assert(last.value.address.country === mockUser2.address.country) + }) + }, + ) + + await t.step( + "Should update documents of KvObject type using deep merge", + async () => { + await useDb(async (db) => { + const cr = await db.is_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const docs = await db.is_users.getMany() + const ids = docs.result.map((doc) => doc.id) + const versionstamps = docs.result.map((doc) => doc.versionstamp) + + const updateData = { + address: { + country: "Ireland", + city: "Dublin", + houseNr: null, + }, + } + + const { result } = await db.is_users.updateManyBySecondaryOrder( + "age", + updateData, + { + limit: 2, + strategy: "merge", + }, + ) + + assert( + result.every((cr) => + cr.ok && ids.includes(cr.id) && + !versionstamps.includes(cr.versionstamp) + ), + ) + + await db.is_users.forEachBySecondaryOrder("age", (doc) => { + assert(doc.value.address.country === updateData.address.country) + assert(doc.value.address.city === updateData.address.city) + assert(doc.value.address.houseNr === updateData.address.houseNr) + assert(doc.value.address.street !== undefined) + }, { limit: 2 }) + + const last = await db.is_users.getOneBySecondaryOrder("age", { + reverse: true, + }) + + assert(last?.value.username === mockUser2.username) + assert(last.value.address.country === mockUser2.address.country) + }) + }, + ) + + await t.step( + "Should only update one document of type KvObject using replace (primary index collision)", + async () => { + await useDb(async (db) => { + const cr = await db.is_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const docs = await db.is_users.getMany() + const ids = docs.result.map((doc) => doc.id) + const versionstamps = docs.result.map((doc) => doc.versionstamp) + + const updateData: User = { + username: "test", + age: 10, + address: { + country: "Norway", + city: "Trondheim", + houseNr: 10, + }, + } + + const { result: crs } = await db.is_users.updateManyBySecondaryOrder( + "age", + updateData, + { + strategy: "replace", + }, + ) + + assert( + crs.some((cr) => + cr.ok && ids.includes(cr.id) && + !versionstamps.includes(cr.versionstamp) + ), + ) + + assert( + crs.some((cr) => !cr.ok), + ) + + const { result } = await db.is_users.mapBySecondaryOrder( + "age", + (doc) => doc.value, + ) + + assertEquals(result[0].username, updateData.username) + assertEquals(result[0].address.country, updateData.address.country) + assertEquals(result[0].address.city, updateData.address.city) + assertEquals(result[0].address.houseNr, updateData.address.houseNr) + assertEquals(result[0].address.street, updateData.address.street) + + assertEquals(result[1].username, mockUser1.username) + assertEquals(result[1].address.country, mockUser1.address.country) + assertEquals(result[1].address.city, mockUser1.address.city) + assertEquals(result[1].address.houseNr, mockUser1.address.houseNr) + assertEquals(result[1].address.street, mockUser1.address.street) + + assertEquals(result[2].username, mockUser2.username) + assertEquals(result[2].address.country, mockUser2.address.country) + assertEquals(result[2].address.city, mockUser2.address.city) + assertEquals(result[2].address.houseNr, mockUser2.address.houseNr) + assertEquals(result[2].address.street, mockUser2.address.street) + }) + }, + ) + + await t.step("Should successfully parse and update", async () => { + await useDb(async (db) => { + const users = generateUsers(10) + let assertion = true + + const cr = await db.zis_users.addMany(users) + assert(cr.ok) + + await db.zis_users.updateManyBySecondaryOrder("age", mockUser1) + .catch(() => assertion = false) + + assert(assertion) + }) + }) + + await t.step("Should fail to parse and update document", async () => { + await useDb(async (db) => { + const users = generateUsers(10) + let assertion = false + + const cr = await db.zis_users.addMany(users) + assert(cr.ok) + + await db.zis_users.updateManyBySecondaryOrder( + "age", + mockUserInvalid, + ).catch(() => assertion = true) + + assert(assertion) + }) + }) + }, +) diff --git a/tests/serialized_indexable_collection/updateOneBySecondaryOrder.test.ts b/tests/serialized_indexable_collection/updateOneBySecondaryOrder.test.ts new file mode 100644 index 0000000..1cf929c --- /dev/null +++ b/tests/serialized_indexable_collection/updateOneBySecondaryOrder.test.ts @@ -0,0 +1,205 @@ +import { assert } from "../test.deps.ts" +import { + mockUser1, + mockUser2, + mockUser3, + mockUserInvalid, + mockUsersWithAlteredAge, +} from "../mocks.ts" +import { useDb } from "../utils.ts" +import type { User } from "../models.ts" + +Deno.test("serialized_indexable_collection - updateOneBySecondaryOrder", async (t) => { + await t.step( + "Should update only one document of KvObject type using shallow merge", + async () => { + await useDb(async (db) => { + const cr = await db.is_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const updateData = { + address: { + country: "Ireland", + city: "Dublin", + houseNr: null, + }, + } + + const updateCr = await db.is_users.updateOneBySecondaryOrder( + "age", + updateData, + { + strategy: "merge-shallow", + }, + ) + + assert(updateCr.ok) + + const { result } = await db.is_users.mapBySecondaryOrder( + "age", + (doc) => doc.value, + ) + + assert(result[0].address.country === updateData.address.country) + assert(result[0].address.city === updateData.address.city) + assert(result[0].address.houseNr === updateData.address.houseNr) + assert(result[0].address.street === undefined) + + assert(result[1].address.country === mockUser1.address.country) + assert(result[1].address.city === mockUser1.address.city) + assert(result[1].address.houseNr === mockUser1.address.houseNr) + assert(result[1].address.street === mockUser1.address.street) + + assert(result[2].address.country === mockUser2.address.country) + assert(result[2].address.city === mockUser2.address.city) + assert(result[2].address.houseNr === mockUser2.address.houseNr) + assert(result[2].address.street === mockUser2.address.street) + }) + }, + ) + + await t.step( + "Should update only one document of KvObject type using deep merge", + async () => { + await useDb(async (db) => { + const cr = await db.is_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const updateData = { + address: { + country: "Ireland", + city: "Dublin", + houseNr: null, + }, + } + + const updateCr = await db.is_users.updateOneBySecondaryOrder( + "age", + updateData, + { + offset: 1, + strategy: "merge", + }, + ) + + assert(updateCr.ok) + + const { result } = await db.is_users.mapBySecondaryOrder( + "age", + (doc) => doc.value, + ) + + assert(result[1].address.country === updateData.address.country) + assert(result[1].address.city === updateData.address.city) + assert(result[1].address.houseNr === updateData.address.houseNr) + assert(result[1].address.street === mockUser1.address.street) + + assert(result[0].address.country === mockUser3.address.country) + assert(result[0].address.city === mockUser3.address.city) + assert(result[0].address.houseNr === mockUser3.address.houseNr) + assert(result[0].address.street === mockUser3.address.street) + + assert(result[2].address.country === mockUser2.address.country) + assert(result[2].address.city === mockUser2.address.city) + assert(result[2].address.houseNr === mockUser2.address.houseNr) + assert(result[2].address.street === mockUser2.address.street) + }) + }, + ) + + await t.step( + "Should update only one document of KvObject type using replace", + async () => { + await useDb(async (db) => { + const cr = await db.is_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const updateData: User = { + username: "test", + age: 10, + address: { + country: "Switzerland", + city: "Bern", + houseNr: null, + }, + } + + const updateCr = await db.is_users.updateOneBySecondaryOrder( + "age", + updateData, + { + strategy: "replace", + }, + ) + + assert(updateCr.ok) + + const { result } = await db.is_users.mapBySecondaryOrder( + "age", + (doc) => doc.value, + ) + + assert(result[0].username === updateData.username) + assert(result[0].age === updateData.age) + assert(result[0].address.country === updateData.address.country) + assert(result[0].address.city === updateData.address.city) + assert(result[0].address.houseNr === updateData.address.houseNr) + assert(result[0].address.street === undefined) + + assert(result[1].username === mockUser1.username) + assert(result[1].address.country === mockUser1.address.country) + assert(result[1].address.city === mockUser1.address.city) + assert(result[1].address.houseNr === mockUser1.address.houseNr) + assert(result[1].address.street === mockUser1.address.street) + + assert(result[2].username === mockUser2.username) + assert(result[2].address.country === mockUser2.address.country) + assert(result[2].address.city === mockUser2.address.city) + assert(result[2].address.houseNr === mockUser2.address.houseNr) + assert(result[2].address.street === mockUser2.address.street) + }) + }, + ) + + await t.step("Should successfully parse and update", async () => { + await useDb(async (db) => { + let assertion = true + + const cr = await db.zis_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + const updateData: User = { + username: "test", + age: 10, + address: { + country: "Switzerland", + city: "Bern", + houseNr: null, + }, + } + + await db.zis_users.updateOneBySecondaryOrder( + "age", + updateData, + ).catch(() => assertion = false) + + assert(assertion) + }) + }) + + await t.step("Should fail to parse and update document", async () => { + await useDb(async (db) => { + let assertion = false + + const cr = await db.zis_users.addMany(mockUsersWithAlteredAge) + assert(cr.ok) + + await db.zis_users.updateOneBySecondaryOrder( + "age", + mockUserInvalid, + ).catch(() => assertion = true) + + assert(assertion) + }) + }) +}) diff --git a/tests/utils.ts b/tests/utils.ts index 8a62fb3..29ea1b2 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,13 +1,14 @@ -import { collection, kvdex } from "../mod.ts" +import { collection, type DenoKv, type DenoKvU64, kvdex } from "../mod.ts" +import { MapKv } from "../src/ext/kv/map_kv.ts" import { model } from "../src/model.ts" import { TransformUserModel, type User, UserSchema } from "./models.ts" // Create test db -export function createDb(kv: Deno.Kv) { +export function createDb(kv: DenoKv) { return kvdex(kv, { - u64s: collection(model()), - s_u64s: collection(model(), { - serialize: "v8", + u64s: collection(model()), + s_u64s: collection(model(), { + serialize: "json", }), users: collection(model()), i_users: collection(model(), { @@ -17,14 +18,14 @@ export function createDb(kv: Deno.Kv) { }, }), s_users: collection(model(), { - serialize: "v8", + serialize: "json", }), is_users: collection(model(), { indices: { username: "primary", age: "secondary", }, - serialize: "v8", + serialize: "json", }), z_users: collection(UserSchema), zi_users: collection(UserSchema, { @@ -34,14 +35,14 @@ export function createDb(kv: Deno.Kv) { }, }), zs_users: collection(UserSchema, { - serialize: "v8", + serialize: "json", }), zis_users: collection(UserSchema, { indices: { username: "primary", age: "secondary", }, - serialize: "v8", + serialize: "json", }), a_users: collection(TransformUserModel), ai_users: collection(TransformUserModel, { @@ -51,23 +52,26 @@ export function createDb(kv: Deno.Kv) { }, }), as_users: collection(TransformUserModel, { - serialize: "v8", + serialize: "json", }), ais_users: collection(TransformUserModel, { indices: { name: "primary", decadeAge: "secondary", }, - serialize: "v8", + serialize: "json", }), }) } // Temporary use functions export async function useKv( - fn: (kv: Deno.Kv) => unknown, + fn: (kv: DenoKv) => unknown, ) { - const kv = await Deno.openKv(":memory:") + const kv = Deno.args[0] === "map" + ? new MapKv() + : await Deno.openKv(":memory:") + const result = await fn(kv) kv.close()