Skip to content
UniKVS
Esc
↑↓navigate↵open⌘Jpreview
On this page

UniKVS

How to use UniKVS, the main package providing the builder and KVS client.

Overview

unikvs is the main client package. Main client Combine middleware and storage with the builder (Builder / UniKvsBuilder), then read and write with the resulting client (UniKvs).

npm install unikvs
pnpm add unikvs
yarn add unikvs
bun add unikvs
nub add unikvs
aube add unikvs

Builder

Create a builder with new Builder(). You can pass the mapping directly as a type parameter, or let it be inferred from a Valibot schema via the schema option. The latter also enables runtime validation of input and output values.

Pass Valibot schemas to PlainValue(), StreamValue(), or Value(). The mapping type is inferred automatically. Install valibot separately when using Valibot schemas.

import * as unikvs from "unikvs";
import * as v from "valibot";

const kvs = new unikvs.Builder({
  schema: {
    foo: unikvs.PlainValue(v.instance(Uint8Array)),
  },
})
  .persist(storage)
  .build();

Use array form for dynamic keys. List key-schema / value-schema pairs; the first match wins.

import * as unikvs from "unikvs";
import * as v from "valibot";

const kvs = new unikvs.Builder({
  schema: [
    [v.pipe(v.string(), v.regex(/^msg-.+/)), unikvs.PlainValue(v.string())],
    [v.pipe(v.string(), v.regex(/^img-.+/)), unikvs.StreamValue(v.instance(Uint8Array))],
  ],
})
  .persist(storage)
  .build();

T is a mapping from key to value ({ [key]: PlainValue | StreamValue | Value }); allowed operations per key are decided at the type level. No runtime validation is performed.

import * as unikvs from "unikvs";

const kvs = new unikvs.Builder<{
  foo: unikvs.Value<Uint8Array>;
}>()
  .persist(storage)
  .build();

Configure in the following order.

`use(middleware)`

Add middleware (optional). You can also add it after registering storage.

`persist(storage)`

Add storage (required, multiple allowed).

`fork(branch)`

Add storage on a branched pipeline (optional). Additions on a branch do not leak to the parent pipeline.

`build(options?)`

Create a UniKvs client. Pass options.beforeAll to insert middleware at the beginning, or options.beforeStorage to insert it immediately before every storage. Throws MissingStorageError when no storage is registered, or UnterminatedPipelineError when the pipeline does not end with a storage.

Each storage uses a snapshot of the pipeline taken when persist() was called.

Writes run against all storages in parallel; reads decode by applying the preceding stages in reverse order, starting from the storage where the key was found.

Branching

Use fork() to add storage on a branched pipeline. Create branches with new Branch(). Additions on a branch do not leak to the parent pipeline. Calling fork() with a branch that reaches no storage throws InvalidForkError.

The following example registers different storages on a branch and on the parent.

import * as unikvs from "unikvs";

const kvs = new unikvs.Builder({
  vars: {},
  schema: {},
})
  .use(new Middleware())
  .fork(new unikvs.Branch().use(new Middleware()).persist(new Storage()))
  .persist(new Storage())
  .build();

Middleware at the beginning

Pass an array of middleware to beforeAll in build() to insert it before every configured middleware. It applies to the main pipeline and every forked branch. Use it when you want to apply a common transformation at the position closest to the caller’s data.

const kvs = new unikvs.Builder()
  .use(new Middleware())
  .persist(storage)
  .build({ beforeAll: [new Middleware()] });

The first element is closest to the caller and the last element is closest to the following middleware.

Middleware immediately before storage

Pass an array of middleware to beforeStorage in build() to insert it immediately before every configured storage. It applies to the main pipeline and every forked branch. Use it when you want to apply a common transformation to multiple storages at the final stage.

const kvs = new unikvs.Builder().persist(storage).build({ beforeStorage: [new Middleware()] });

The first element is closer to the existing pipeline and the last element is closer to the storage.

Validating the configuration

isValid() reports whether at least one storage is registered and the pipeline ends with a storage. A branch that reaches no storage throws InvalidForkError at fork() time, so branch validity is guaranteed when the fork is added.

const withStorage = new unikvs.Builder().persist(mainStorage);
withStorage.isValid(); // true

const withoutStorage = new unikvs.Builder();
withoutStorage.isValid(); // false

Client operations

The created client provides the following operations. Call open() to initialize and close() when done (await using also auto-closes).

Method Description
open() Initialize storage and middleware.
close() Close all storage and middleware.
set(key, value) Store a value under a key.
get(key) Get the value for a key.
stream(key) Get a stream for a key.
has(key) Check whether a key exists.
delete(key) Delete a key.
copy(key, destinationKey) Copy a key’s value to another key.
move(key, destinationKey) Move a key’s value to another key.
clear() Delete all data.

All operations support cancellation via AbortSignal and per-operation runtime variables (vars). Arguments can be passed either as an options object or positionally.

const ac = new AbortController();
setTimeout(() => ac.abort(), 1000);

await kvs.set("foo", new Uint8Array([0, 1, 2]), {
  signal: ac.signal,
  vars: { traceId: "abc-123" },
});

const bytes = await kvs.get("foo", { signal: ac.signal });

await kvs.copy("foo", "backup");
await kvs.move({ key: "backup", destinationKey: "archive" });

copy and move take the source key and the destination destinationKey. An existing destination value is overwritten. move removes the source only after the destination write succeeds. If the source does not exist, they throw KeyNotFoundError. If both keys are the same, they only verify that the source exists and do not delete it.

With multiple storages, the operation is applied to every storage where the source exists. Middleware mapKey is applied to both the source and destination keys. When a storage does not implement copy or move, the operation is reproduced with read, write, and delete.

Value types

PlainValue, StreamValue, and Value are both types and functions. As types they restrict which methods each key supports; as functions they infer types from Valibot schemas.

Type Write Read Stream read
PlainValue<T> set(key, T) get(key): T Not allowed.
StreamValue<T> set(key, T | ReadableStream<T>) Not allowed. stream(key): ValueStream<T>
Value<T> set(key, T | ReadableStream<T>) get(key): T stream(key): ValueStream<T>

Value<T> is sugar for both, equivalent to PlainValue<T> | StreamValue<T>.

import * as unikvs from "unikvs";

const kvs = new unikvs.Builder<{
  message: unikvs.PlainValue<string>;
  logs: unikvs.StreamValue<Uint8Array>;
}>()
  .persist(storage)
  .build();

await kvs.open();

await kvs.set("message", "hello");
const msg = await kvs.get("message");
// msg is a string

await kvs.set("logs", new Uint8Array([0x01]));
const valueStream = await kvs.stream("logs");
import * as unikvs from "unikvs";
import * as v from "valibot";

const kvs = new unikvs.Builder({
  schema: {
    message: unikvs.PlainValue(v.string()),
    logs: unikvs.StreamValue(v.instance(Uint8Array)),
  },
})
  .persist(storage)
  .build();

await kvs.open();

await kvs.set("message", "hello");
const msg = await kvs.get("message");
// msg is a string

Disallowed combinations are type errors. With schema, they also throw InvalidInputError at runtime.

// Type error: stream() cannot be used on a PlainValue key
kvs.stream("message");

// Type error: get() cannot be used on a StreamValue key
kvs.get("logs");

Schema validation

When schema is given, input and output values are validated with Valibot schemas.

Operation What is validated Error on failure
set Validates the input value. InvalidInputError
get Validates the output value. InvalidOutputError
stream Validates each chunk. InvalidOutputError
has/delete Array form only: validates the key. InvalidInputError
copy/move Validates both the source and destination keys. InvalidInputError
import * as unikvs from "unikvs";
import * as v from "valibot";

const kvs = new unikvs.Builder({
  schema: {
    message: unikvs.PlainValue(v.pipe(v.string(), v.minLength(1))),
  },
})
  .persist(storage)
  .build();

await kvs.open();

await kvs.set("message", "hello");
await kvs.get("message"); // "hello"

// Fails input validation and throws InvalidInputError
await kvs.set("message", "");

ValueStream

stream() returns a ValueStream<T>, a wrapper around ReadableStream. It supports for await...of and auto-disposal with await using.

const reader = (await kvs.stream("logs")).getReader();
while (true) {
  const { done, value } = await reader.read();
  if (done) {
    break;
  }
  console.log(value); // Uint8Array
}
for await (const chunk of await kvs.stream("logs")) {
  console.log(chunk); // Uint8Array
}
await using valueStream = await kvs.stream("logs");
const reader = valueStream.getReader();
const { value } = await reader.read();
console.log(value);

Variables and cancellation

Pass per-operation settings with vars.

const kvs = new unikvs.Builder<{ foo: unikvs.Value<Uint8Array> }>().persist(storage).build();

await kvs.open();

// Pass vars per operation
await kvs.set("foo", new Uint8Array([1]), {
  vars: { region: "us-east-1" },
});

// Array form also works
await kvs.get("foo", {
  vars: [["region", "us-east-1"]],
});

Cancel with an AbortSignal; an aborted operation fails with the abort reason.

const ac = new AbortController();
setTimeout(() => ac.abort(), 1000);

await kvs.set("foo", new Uint8Array([1]), { signal: ac.signal });

Base vars

Set base vars with new Builder({ vars }). They are merged with each operation’s vars, with the per-operation side winning. You can pass a Record, Map, entry array, or Variables. Malformed input throws InvalidInputError.

const kvs = new unikvs.Builder<{ foo: unikvs.Value<Uint8Array> }>({
  vars: { region: "us-east-1" },
})
  .persist(storage)
  .build();

await kvs.open();

// Merged with per-operation vars. The per-operation `region` wins
await kvs.set("foo", new Uint8Array([1]), {
  vars: { region: "eu-west-1", traceId: "abc-123" },
});
// Runtime variables are { region: "eu-west-1", traceId: "abc-123" }

build() snapshots the base vars at call time. Builders are reusable; later builder changes do not affect already created clients.

Both the builder (Builder) and the client (UniKvs) have same-named methods to change base vars. Each returns itself for chaining. Client-side changes take effect from subsequent operations.

setVars

Replace base vars entirely.

builder.setVars({ region: "us-east-1" });
kvs.setVars({ region: "us-east-1" });

assignVars

Overwrite base vars with the given vars. Keys in the argument win.

// When base vars are { a: 1, b: 2 }
builder.assignVars({ b: 20, c: 3 });
// Base vars become { a: 1, b: 20, c: 3 }

defaultsVars

Fill defaults: the current base vars win over the given vars.

// When base vars are { a: 1, b: 2 }
builder.defaultsVars({ b: 20, c: 3 });
// Base vars become { a: 1, b: 2, c: 3 }

Read repair

get and stream accept repair: true to write values found in later storage back to earlier storage (default false). A denied write-back still succeeds the read by default and leaves a warning log. Pass throwOnRepairDenied: true to fail the read instead (default false). Control write-back eligibility with @unikvs/allow-repair; on denial, meta.names holds the display names of the write-back targets. See using multiple backends for details.

const bytes = await kvs.get("foo", { repair: true });

Errors

Error class Description
KeyNotFoundError The key exists in no storage.
UniKvsIsNotOpenError Operated before opening.
UniKvsIsOpenError Called open() while already open.
MissingStorageError Called build() with no storage registered.
UnterminatedPipelineError Called build() while the pipeline did not end with a storage.
InvalidForkError Called fork() with a branch that reaches no storage.
StorageIsNotOpenError Storage is not open.
TransformerIsNotOpenError Middleware is not open.
PluginOperationAggregateError Aggregate error of multi-plugin operations.
InvalidInputError Malformed input.
InvalidOutputError Malformed output.
ReadableStreamNotSupportedError Read streaming is not supported.
WritableStreamNotSupportedError Write streaming is not supported.
EncodableStreamNotSupportedError Encoding streams are not supported.
DecodableStreamNotSupportedError Decoding streams are not supported.
import * as unikvs from "unikvs";

try {
  const bytes = await kvs.get("missing-key");
} catch (ex) {
  if (ex instanceof KeyNotFoundError) {
    console.log("Key not found");
  } else {
    throw ex;
  }
}

For error classification and handling, see handling errors.

Examples

Minimal example using @unikvs/memory.

import { Memory } from "@unikvs/memory";
import * as unikvs from "unikvs";

const kvs = new unikvs.Builder<{
  foo: unikvs.Value<Uint8Array>;
}>()
  .persist(new Memory())
  .build();

await kvs.open();

await kvs.set("foo", new Uint8Array([0, 1, 2]));

const bytes = await kvs.get("foo");
console.log(bytes); // Uint8Array(3) [0, 1, 2]

console.log(await kvs.has("foo")); // true

await kvs.delete("foo");
await kvs.close();
import { Memory } from "@unikvs/memory";
import * as unikvs from "unikvs";
import * as v from "valibot";

const kvs = new unikvs.Builder({
  schema: {
    foo: unikvs.PlainValue(v.instance(Uint8Array)),
  },
})
  .persist(new Memory())
  .build();

await kvs.open();

await kvs.set("foo", new Uint8Array([0, 1, 2]));

const bytes = await kvs.get("foo");
console.log(bytes); // Uint8Array(3) [0, 1, 2]

await kvs.close();

Was this page helpful?