Skip to content

Commit

Permalink
Add generics. Update docs.
Browse files Browse the repository at this point in the history
  • Loading branch information
Hexagon committed May 22, 2024
1 parent 42b86b3 commit d08c8aa
Show file tree
Hide file tree
Showing 6 changed files with 83 additions and 85 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 0.12.2

- Only throw in `.sync()` on closed database if the force parameter is true.
- Make all relevant methods (`.get()`, `.set()`, `.iterate()`, `.listAll()` ...)
type safe using generics
- Update docs

## 0.12.1

- Add method `.isOpen()`
Expand Down
100 changes: 45 additions & 55 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
# cross/kv: A Fast Key/Value Database for Node, Deno and Bun.
# cross/kv: Fast Key/Value Database for Node, Deno and Bun.

[![JSR](https://jsr.io/badges/@cross/kv)](https://jsr.io/@cross/kv)
[![JSR Score](https://jsr.io/badges/@<scope>/@cross/kv)](https://jsr.io/@cross/kv)
[![JSR Score](https://jsr.io/badges/@cross/kv)](https://jsr.io/@cross/kv)

An in-memory indexed and file based Key/Value database for JavaScript and
TypeScript, designed for seamless multi-process access and compatibility across
Node.js, Deno, and Bun.
A lightweight, fast, powerful and cross-platform key-value database for Node.js,
Deno, and Bun.

```typescript
import { KV } from "@cross/kv";
Expand Down Expand Up @@ -35,23 +34,26 @@ db.close();

## Features

- **Efficient Key-Value Storage:** Rapid storage and retrieval using
hierarchical keys and a high-performance in-memory index.
- **Durable Transactions:** Ensure data integrity and recoverability through an
append-only transaction ledger.
- **Atomic Transactions:** Guarantee data consistency by grouping multiple
operations into a single, indivisible unit.
- **Optimized Storage:** Reclaim disk space and maintain performance through
vacuuming operations.
- **Cross-Platform & Multi-Process:** Built in pure TypeScript, working
seamlessly across Node.js, Deno, and Bun, supporting concurrent access by
multiple processes.
- **Flexible & Customizable:** Store any JavaScript object, subscribe to data
changes, and fine-tune synchronization behavior.
## Features

## Installation
- **Cross-Platform & Multi-Process:** Built with pure TypeScript for seamless
compatibility across Node.js, Deno, and Bun, with built-in support for
concurrent access by multiple processes.
- **Powerful:** Supports hierarchical keys, flexible mid-key range queries, and
real-time data change notifications through `.watch()`.
- **Simple and Fast:** Lightweight and performant storage with an in-memory
index for efficient data retrieval.
- **Durable:** Ensures data integrity and reliability by storing each database
as a single, append-only transaction ledger.
- **Type-Safe:** Leverages TypeScript generics for enhanced type safety when
setting and retrieving values.
- **Atomic Transactions:** Guarantees data consistency by grouping multiple
operations into indivisible units, which also improves performance.
- **Flexible:** Store any serializable JavaScript object (except functions and
WeakMaps), and customize synchronization behavior to optimize for your
specific use case.

Full installation instructions available at <https://jsr.io/@cross/kv>
## Installation

```bash
# Using npm
Expand All @@ -72,18 +74,18 @@ bunx jsr add @cross/kv
are optional.
- `async open(filepath, createIfMissing)` - Opens the KV store.
`createIfMissing` defaults to true.
- `async set(key, value)` - Stores a value.
- `async get(key)` - Retrieves a value.
- `async *iterate(query)` - Iterates over entries for a key.
- `async set<T>(key, value)` - Stores a value.
- `async get<T>(key)` - Retrieves a value.
- `async *iterate<T>(query)` - Iterates over entries for a key.
- `listKeys(query)` - List all keys under <query>.
- `async listAll(query)` - Gets all entries for a key as an array.
- `async listAll<T>(query)` - Gets all entries for a key as an array.
- `async delete(key)` - Deletes a key-value pair.
- `async sync()` - Synchronizez the ledger with disk.
- `watch(query, callback, recursive): void` - Registers a callback to be
- `watch<T>(query, callback, recursive): void` - Registers a callback to be
called whenever a new transaction matching the given query is added to the
database.
- `unwatch(query, callback): void` - Unregisters a previously registered watch
handler.
- `unwatch<T>(query, callback): void` - Unregisters a previously registered
watch handler.
- `beginTransaction()` - Starts an atomic transaction.
- `async endTransaction()` - Ends an atomic transaction, returns a list of
`Errors` if any occurred.
Expand Down Expand Up @@ -126,9 +128,9 @@ including:

### Queries

Queries are basically keys, but with additional support for ranges, which are
Queries are similar to keys but with additional support for ranges, specified as
objects like `{ from: 5, to: 20 }` or `{ from: "a", to: "l" }`. An empty range
(`{}`) means any document.
(`{}`) matches any document.

**Example queries**

Expand Down Expand Up @@ -159,36 +161,24 @@ transaction.

### Single-Process Synchronization

In single-process scenarios, explicit synchronization is unnecessary. You can
disable automatic synchronization by setting the `autoSync` option to false, and
do not have to care about running `.sync()`. This can improve performance when
only one process is accessing the database.
In single-process scenarios, explicit synchronization is often unnecessary. You
can disable automatic synchronization by setting the `autoSync` option to
`false`, eliminating automated `.sync()` calls. This can potentially improve
performance when only one process accesses the database.

### Multi-Process Synchronisation

In multi-process scenarios, synchronization is crucial to ensure data
consistency across different processes. `cross/kv` manages synchronization in
the following ways:

- **Automatic Index Synchronization:** The index is automatically synchronized
at a set interval (default: 1000ms), ensuring that changes made by other
processes are reflected in all instances within a maximum of `syncIntervalMs`
milliseconds. You can adjust this interval using the `syncIntervalMs` option.
In multi-process scenarios, synchronization is essential for maintaining data
consistency. `cross/kv` offers automatic index synchronization upon each data
insertion and at a configurable interval (default: 1000ms). Customizing this
interval providing fine-grained control over the trade-off between consistency
and performance. For strict consistency guarantees, you can manually call
`.sync()` before reading data.

- **Manual Synchronization for Reads:** When reading data, you have two options:

- **Accept Potential Inconsistency:** By default, reads do not trigger an
immediate synchronization, which can lead to a small window of inconsistency
if another process has recently written to the database. This is generally
acceptable for most use cases.

- **Force Synchronization:** For strict consistency, you can manually trigger
synchronization before reading using the `.sync()` method:

```ts
await kv.sync(); // Ensure the most up-to-date data
const result = await kv.get(["my", "key"]); // Now read with confidence
```
```ts
await kv.sync(); // Ensure the most up-to-date data
const result = await kv.get(["my", "key"]); // Now read with confidence
```

### Monitoring Synchronization Events

Expand Down
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cross/kv",
"version": "0.12.1",
"version": "0.12.2",
"exports": {
".": "./mod.ts"
},
Expand Down
39 changes: 20 additions & 19 deletions src/kv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,15 @@ export interface KVSyncResult {
/**
* A function that is called when a watched transaction occurs.
*/
export interface WatchHandler {
export interface WatchHandler<T> {
/**
* The query used to filter the transactions.
*/
query: KVQuery;
/**
* The callback function that will be called when a transaction matches the query.
*/
callback: (transaction: KVTransactionResult) => void;
callback: (transaction: KVTransactionResult<T>) => void;
/**
* Whether to include child keys
*/
Expand Down Expand Up @@ -95,7 +95,7 @@ export class KV extends EventEmitter {
private index: KVIndex = new KVIndex();
private ledger?: KVLedger;
private pendingTransactions: KVTransaction[] = [];
private watchHandlers: WatchHandler[] = [];
private watchHandlers: WatchHandler<any>[] = [];

// Configuration
private ledgerPath?: string;
Expand Down Expand Up @@ -244,7 +244,7 @@ export class KV extends EventEmitter {
*/
public async sync(force = false, doLock = true): Promise<KVSyncResult> {
// Throw if database isn't open
this.ensureOpen();
if (force) this.ensureOpen();

// Ensure ledger is open and instance is not closed
if (this.ledger?.isClosing() || this.aborted) {
Expand Down Expand Up @@ -403,11 +403,13 @@ export class KV extends EventEmitter {
* @param key - Representation of the key.
* @returns A promise that resolves to the retrieved value, or null if not found.
*/
public async get(key: KVKey): Promise<KVTransactionResult | null> {
public async get<T = unknown>(
key: KVKey,
): Promise<KVTransactionResult<T> | null> {
// Throw if database isn't open
this.ensureOpen();

for await (const entry of this.iterate(key, 1)) {
for await (const entry of this.iterate<T>(key, 1)) {
return entry;
}
return null;
Expand Down Expand Up @@ -435,10 +437,10 @@ export class KV extends EventEmitter {
* const allEntries = await kvStore.list(["users"]);
* console.log(allEntries);
*/
public async *iterate(
public async *iterate<T = unknown>(
key: KVQuery,
limit?: number,
): AsyncGenerator<KVTransactionResult> {
): AsyncGenerator<KVTransactionResult<T>> {
// Throw if database isn't open
this.ensureOpen();

Expand Down Expand Up @@ -469,12 +471,14 @@ export class KV extends EventEmitter {
* @param key - Representation of the key to query.
* @returns A Promise that resolves to an array of all matching data entries.
*/
public async listAll(key: KVQuery): Promise<KVTransactionResult[]> {
public async listAll<T = unknown>(
key: KVQuery,
): Promise<KVTransactionResult<T>[]> {
// Throw if database isn't open
this.ensureOpen();

const entries: KVTransactionResult[] = [];
for await (const entry of this.iterate(key)) {
const entries: KVTransactionResult<T>[] = [];
for await (const entry of this.iterate<T>(key)) {
entries.push(entry);
}
return entries;
Expand Down Expand Up @@ -507,10 +511,7 @@ export class KV extends EventEmitter {
* @param key - Representation of the key.
* @param value - The value to store.
*/
public async set(
key: KVKey,
value: any,
): Promise<void> {
public async set<T = unknown>(key: KVKey, value: T): Promise<void> {
// Throw if database isn't open
this.ensureOpen();

Expand Down Expand Up @@ -669,9 +670,9 @@ export class KV extends EventEmitter {
* @param query - The query to match against new transactions.
* @param callback - The callback function to be called when a match is found. The callback will receive the matching transaction as its argument.
*/
public watch(
public watch<T = unknown>(
query: KVQuery,
callback: (transaction: KVTransactionResult) => void,
callback: (transaction: KVTransactionResult<T>) => void,
recursive: boolean = false,
) {
this.watchHandlers.push({ query, callback, recursive });
Expand All @@ -687,9 +688,9 @@ export class KV extends EventEmitter {
*
* @returns True on success
*/
public unwatch(
public unwatch<T = unknown>(
query: KVQuery,
callback: (transaction: KVTransactionResult) => void,
callback: (transaction: KVTransactionResult<T>) => void,
): boolean {
const newWatchHandlers = this.watchHandlers.filter(
(handler) => handler.query !== query || handler.callback !== callback,
Expand Down
10 changes: 5 additions & 5 deletions src/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export type KVTransactionData = Uint8Array;
/**
* Represents a single transaction result from the Key-Value store.
*/
export interface KVTransactionResult {
export interface KVTransactionResult<T> {
/**
* The key associated with the transaction.
*/
Expand All @@ -98,7 +98,7 @@ export interface KVTransactionResult {
* For SET operations, this will be the value that was set.
* For DELETE operations, this will typically be null or undefined.
*/
data: unknown;
data: T;

/**
* The hash of the raw transaction data. This can be used for
Expand Down Expand Up @@ -242,7 +242,7 @@ export class KVTransaction {
return fullData;
}

private getData(): unknown | null {
private getData<T>(): T | null {
// Return data, should be validated through create or fromUint8Array
if (this.data) {
return decode(this.data);
Expand All @@ -255,7 +255,7 @@ export class KVTransaction {
* Converts the transaction to a KVTransactionResult object.
* This assumes that the transaction's data is already validated or created correctly.
*/
public asResult(): KVTransactionResult {
public asResult<T>(): KVTransactionResult<T> {
if (
this.operation === undefined || this.timestamp === undefined ||
this.hash === undefined
Expand All @@ -268,7 +268,7 @@ export class KVTransaction {
key: this.key!.get() as KVKey,
operation: this.operation,
timestamp: this.timestamp,
data: this.getData(),
data: this.getData() as T,
hash: this.hash,
};
}
Expand Down
10 changes: 5 additions & 5 deletions test/kv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ test("KV: iteration with limit", async () => {

// Iterate with a limit of 3
const limit = 3;
const results: KVTransactionResult[] = [];
const results: KVTransactionResult<unknown>[] = [];
for await (const entry of kvStore.iterate(["data"], limit)) {
results.push(entry);
}
Expand Down Expand Up @@ -383,7 +383,7 @@ test("KV: sync event triggers and reflects data changes", async () => {
await kvStore1.open(tempFilePrefix);
await kvStore2.open(tempFilePrefix);

let syncedData: KVTransactionResult[] = [];
let syncedData: KVTransactionResult<unknown>[] = [];

// Listen for the "sync" event on the second instance
// @ts-ignore ksStore2 is an EventEmitter
Expand Down Expand Up @@ -415,7 +415,7 @@ test("KV: watch functionality - basic matching", async () => {
await kvStore.open(tempFilePrefix);

const watchedKey = ["user", "profile"];
let receivedTransaction: KVTransactionResult | null = null;
let receivedTransaction: KVTransactionResult<unknown> | null = null;

// Watch for a specific key
kvStore.watch(watchedKey, (transaction) => {
Expand All @@ -436,7 +436,7 @@ test("KV: watch functionality - recursive matching", async () => {
const kvStore = new KV({ autoSync: false });
await kvStore.open(tempFilePrefix);

const receivedTransactions: KVTransactionResult[] = [];
const receivedTransactions: KVTransactionResult<unknown>[] = [];

const query: KVQuery = ["users"];

Expand All @@ -461,7 +461,7 @@ test("KV: watch functionality - range matching", async () => {
const kvStore = new KV({ autoSync: false });
await kvStore.open(tempFilePrefix);

const receivedTransactions: KVTransactionResult[] = [];
const receivedTransactions: KVTransactionResult<unknown>[] = [];

kvStore.watch(["scores", { from: 10, to: 20 }], (transaction) => {
receivedTransactions.push(transaction);
Expand Down

0 comments on commit d08c8aa

Please sign in to comment.