Json Key-value Storage for TypeScript.
Damn simple typescript database. String keys. Json values.
Kv is an agnostic interface. You insert different drivers, which allows Kv to write data in-memory, or to local storage, or to leveldb, or wherever you want.
Kv does smart stuff, like namespacing, batch operations, and atomic write transactions.
npm install @e280/kv
- Kv uses the in-memory
MemDriver
by defaultimport {Kv} from "@e280/kv" const kv = new Kv()
- or alternatively, pop in a
LevelDriver
to use leveldb, a local on-disk database (kinda like sqlite)import {Kv} from "@e280/kv" import {LevelDriver} from "@e280/kv/level" const kv = new Kv(new LevelDriver("path/to/database"))
- or alternatively, pop in a
StorageDriver
to use browser localStorageimport {Kv, StorageDriver} from "@e280/kv" const kv = new Kv(new StorageDriver())
- The most basic thing you can do with Kv, is write and read values using string keys.
await kv.set("101", "hello") await kv.set("102", 123.456) await kv.get("101") // "hello" await kv.get("102") // 123.456 await kv.get("103") // undefined
- so, for my use case, i'm doing stuff like saving user accounts, it might give you an idea of how Kv is meant to be used
// create a kv instance const kv = new Kv() // creating some typed scopes for which i'll insert records const accounts = kv.scope<Account>("accounts") const characters = kv.scope<Character>("characters") // my app's function for adding a character to an account async function addCharacter(accountId: string, character: Character) { // obtain the account const account = await accounts.require(accountId) // actually uses key `accounts:${accountId}` because of the scope prefix // modifying the data character.ownerId = account.id account.characterIds.push(character.id) // create an atomic write transaction to save the data await kv.transaction(() => [ accounts.write.set(account.id, account), characters.write.set(character.id, character), ]) } // my app's function for listing all characters async function listCharacters(accountId: string) { const account = await accounts.require(accountId) return characters.requires(...account.characterIds) }
set
saves key-value pairsawait kv.set("hello", "world")
set
can save any serializable json-friendly javascript crapawait kv.set("hello", {data: ["world"], count: 123.456})
set
will interpretundefined
as the same as a deletion (like json)await kv.set("hello", undefined) // same as deleting "hello"
- like json you can use
null
instead of you want the key to exist
- like json you can use
sets
saves many pairs, as an atomic batchawait kv.sets(["101", "alpha"], ["102", "bravo"])
get
loads a value (or undefined if the key's not found)await kv.get("101") // "alpha" (or undefined)
gets
loads many values at once (undefined for not-found keys)await kv.gets("101", "102", "103") // ["alpha", "bravo", undefined]
del
deletes thingsawait kv.del("hello")
del
can also delete many thingsawait kv.del("101", "102", "103")
has
checks if a key existsawait kv.has("hello") // true (or false)
hasKeys
checks many keysawait kv.hasKeys("101", "102", "103") // [true, true, false]
require
gets a value, but throws an error if the key is missingawait kv.require("101") // "world" (or an error is thrown)
requires
gets many things, throws an error if any keys are missingawait kv.requires("101", "102") // ["alpha", {data: 123.45}] (or an error is thrown)
guarantee
gets or creates a thingawait kv.guarantee("hello", () => "world") // "world"
- make an atomic transaction, where the writes happen all-or-nothing to avoid corruption
// all these succeed or fail together await kv.transaction(write => [ write.del("obsolete:99"), write.set("owners:4", [101, 102]), write.sets( ["records:101", {msg: "lol", owner: 4}], ["records:102", {msg: "lel", owner: 4}], ), ])
- you can use
write.set
,write.sets
, andwrite.del
to schedule write operations into the transaction
- you can use
- a scope is just a Kv instance that has a key prefix assigned
const records = kv.scope("records") // writes to key "records:123" await records.set("123", "lol")
- a scope can do everything a Kv can do (it is a Kv)
const records = kv.scope("records") await records.set("124", {data: "bingus"}) await records.transaction(write => [write.del("124")])
- yes, you can scope a scope β it's turtles all the way down
const records = kv.scope("records") const owners = records.scope("owners") const accounts = records.scope("accounts") // writes to key "records.owners:5" await owners.set("5", "lol") // writes to key "records.accounts:123" await accounts.set("123", "rofl")
- you can constrain a scope with a typescript type
type MyData = {count: number} // provide your type // π const records = kv.scope<MyData>("records") // now typescript knows `count` is a number const {count} = records.get("123")
- you can in fact do transactional writes across multiple scopes
const records = kv.scope("records") const owners = records.scope("owners") const accounts = records.scope("accounts") await kv.transaction(() => [ owners.write.set("5", {records: [101, 102]}), accounts.write.set("101", {data: "alpha", owner: 5}), accounts.write.set("102", {data: "bravo", owner: 5}), ])
- scopes automatically place a ":" delimiter to separate namespaces from subkeys
const records = kv.scope("records") const alpha = records.scope("alpha") await records.set(1, "hello") // writes to key "records:1" await alpha.set(2, "hello") // writes to key "records.alpha:2"
- no keys will collide between
records.keys()
andalpha.keys()
- however, you can deliberately flatten a scope, which allows you to select all keys across all sub scopes
const flatRecords = records.flatten() for await (const key of records.keys()) console.log(key) // "records:1" // "records.alpha:2"
- no keys will collide between
- a store is an object that focuses on reading/writing the value of a single key
const login = kv.store<Login>("login") // save data to the store await login.set({token: "lol"}) // load data from the store const {token} = await login.get()
- if you want Kv to operate on a new database, it's pretty easy to write a new Driver
- here is the abstract Driver class you'd have to extend
export abstract class Driver { abstract gets(...keys: string[]): Promise<(string | undefined)[]> abstract hasKeys(...keys: string[]): Promise<boolean[]> abstract keys(scan?: Scan): AsyncGenerator<string> abstract entries(scan?: Scan): AsyncGenerator<[string, string]> abstract transaction(...writes: Write[]): Promise<void> }
- then you can just provide your new driver to the Kv constructor, eg
// instance your new driver and give it to Kv const kv = new Kv(new MyDriver())
- see drivers/mem.ts
- see drivers/level.ts
- see drivers/storage.ts
- you can do it!
- free and open source
- build with us at https://e280.org/ but only if you're cool
- star this on github if you think it's cool