@temelj/storage
v0.5.0
Published
A unified key-value storage interface.
Readme
Installation
# npm
$ npm install @temelj/storage
# jsr
$ deno add jsr:@temelj/storage # or jsr add @temelj/storageUsage
@temelj/storage provides a common async storage API over different backing data stores.
Values use the SuperJSON codec by default, so JSON-compatible data, Date, bigint, Map, Set, RegExp, Uint8Array, and Temporal.* values can be stored without choosing a codec.
import { createStorage } from "@temelj/storage";
const storage = createStorage();
await storage.setMany([
{ key: "users:1", value: { name: "Verso" } },
{ key: "users:2", value: { name: "Maelle" } },
{ key: "drafts:1", value: { title: "Lumière" } },
]);
const user = await storage.get("users:1");
await storage.delete("users:1");Use try* methods when failures should be handled as Result values with @temelj/result instead of throwing exceptions.
import { isOk } from "@temelj/result";
const result = await storage.tryGet("users:1");
if (isOk(result)) {
console.log(result.value);
}Typed item maps can describe known keys.
type AppStorage = {
readonly "settings:theme": "dark" | "light";
readonly "users:1": {
readonly id: string;
readonly name: string;
};
};
const storage = createStorage<AppStorage>();
await storage.set("settings:theme", "dark");
const theme = await storage.get("settings:theme");Codecs
Engines persist string, number, Uint8Array, or their union.
A codec must produce the exact representation accepted by its engine.
import { createStorage, createTextStorageCodec } from "@temelj/storage";
const storage = createStorage({
codec: createTextStorageCodec(),
engine: new InMemoryStorageEngine<string>(),
});
await storage.set("message", "hello");Available codecs are:
createSuperJsonStorageCodecfor rich JavaScript values. This is the default.createJsonStorageCodecfor JSON-compatible values.createPrimitivizedJsonStorageCodecfor values normalized through@temelj/value.createTextStorageCodecfor strings.createBytesStorageCodecforUint8Arrayvalues.createIdentityStorageCodecfor numbers, exact raw types, or mixedStoredValue.
The JSON, primitivized JSON, and SuperJSON codecs accept format: "string" | "bytes" and default to strings.
The format literal determines the codec's persisted type. Omit the options argument for strings, or provide an explicit format option. A type argument alone cannot select bytes.
Redis and Cloudflare KV engines require format: "string" | "bytes". The SQL engines require format: "string" | "number" | "bytes" and check that persisted values match it when reading. Their generic argument is the format literal, such as PostgresStorageEngine<"bytes">.
const textTable = createStorage({
codec: createJsonStorageCodec({ format: "string" }),
engine: new LibSqlStorageEngine({ format: "string", client }),
});
const binaryFiles = createStorage({
codec: createSuperJsonStorageCodec({ format: "bytes" }),
engine: new FileSystemStorageEngine({ directory: "./data" }),
});
const counters = createStorage({
codec: createIdentityStorageCodec<number>(),
engine: new InMemoryStorageEngine<number>(),
});
const raw = createStorage({
codec: createIdentityStorageCodec<StoredValue>(),
engine: new InMemoryStorageEngine<StoredValue>(),
});Events and watching
Storage expose typed event listeners for local mutations.
const off = storage.on("storage:change", (event) => {
if (event.type === "set") {
console.log("updated", event.key);
}
});
await storage.set("users:1", { name: "Aline" });
off();Specific events are available as storage:set, storage:set-many, storage:delete, storage:delete-many, and storage:clear.
Use storage:* or * patterns to listen broadly.
const offSet = storage.once("storage:set", (event) => {
console.log(event.key, event.source);
});
console.log(storage.listenerCount("storage:set"));
offSet();
storage.clearListeners();Use watch when you also want to subscribe to engine-native change notifications.
This starts the engine watcher only while watch callbacks are active.
const unwatch = await storage.watch((event) => {
console.log(event.type, event.source);
});
await storage.set("users:1", { name: "Renoir" });
await unwatch();Events from storage methods use source: "storage".
Events reported by an engine watch implementation use source: "engine".
Storage engines
You can choose between several storage engine providers or create your own.
In-Memory Storage
In-memory storage is the default engine.
import { createStorage } from "@temelj/storage";
const storage = createStorage();
await storage.set("sessions:1", "active", { ttl: 60_000 });You can also create the engine explicitly.
import { InMemoryStorageEngine, createStorage } from "@temelj/storage";
const storage = createStorage({
engine: new InMemoryStorageEngine(),
});File System Storage
File system storage supports literal storage keys, prefixes, and TTL metadata.
The engine stores one encoded value file per key and, when a value expires,
one metadata file with the expiration timestamp. File extensions can be
customized with valueExtension and metadataExtension; defaults are exported
as DEFAULT_VALUE_EXTENSION and DEFAULT_METADATA_EXTENSION.
import { createStorage } from "@temelj/storage";
import { FileSystemStorageEngine } from "@temelj/storage/filesystem";
const storage = createStorage({
codec: createSuperJsonStorageCodec({ format: "bytes" }),
engine: new FileSystemStorageEngine({
directory: "./.storage",
prefix: "app",
}),
});
await storage.set("users:1", { name: "Verso" });import { FileSystemStorageEngine } from "@temelj/storage/filesystem";
const storage = createStorage({
codec: createSuperJsonStorageCodec({ format: "bytes" }),
engine: new FileSystemStorageEngine({
directory: "./.storage",
metadataExtension: ".ttl.json",
valueExtension: ".value",
}),
});Redis Storage
Redis storage uses ioredis. Install the optional peer dependency when using this engine.
import { createStorage } from "@temelj/storage";
import { RedisStorageEngine } from "@temelj/storage/redis";
const storage = createStorage({
engine: new RedisStorageEngine({
format: "bytes",
url: process.env.REDIS_URL,
prefix: "app",
scanCount: 100,
}),
});
await storage.set("sessions:1", "active", { ttl: 60_000 });Cloudflare Storage
Cloudflare KV, D1, and R2 are separate engines.
Each engine accepts a Worker binding directly, or a binding name together with the Worker's env object.
They also support remote HTTP APIs.
Workers KV
KV HTTP mode uses the optional cloudflare peer dependency.
import { createStorage } from "@temelj/storage";
import { CloudflareKvStorageEngine, type CloudflareKvBinding } from "@temelj/storage/cloudflare";
export default {
async fetch(
_request: Request,
env: { readonly STORAGE: CloudflareKvBinding },
): Promise<Response> {
const storage = createStorage({
engine: new CloudflareKvStorageEngine({
format: "bytes",
binding: env.STORAGE,
prefix: "app",
}),
});
await storage.set("users:1", { name: "Verso" });
return Response.json(await storage.get("users:1"));
},
};KV HTTP mode uses accountId, namespaceId, and Cloudflare client options.
import { createStorage } from "@temelj/storage";
import { CloudflareKvStorageEngine } from "@temelj/storage/cloudflare";
const storage = createStorage({
engine: new CloudflareKvStorageEngine({
format: "bytes",
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
apiToken: process.env.CLOUDFLARE_API_TOKEN,
namespaceId: process.env.CLOUDFLARE_KV_NAMESPACE_ID,
}),
});D1 stores encoded values in a SQLite table named temelj_storage by default.
The table is created lazily.
import { createStorage } from "@temelj/storage";
import { CloudflareD1StorageEngine, type CloudflareD1Binding } from "@temelj/storage/cloudflare";
export default {
async fetch(
_request: Request,
env: { readonly DATABASE: CloudflareD1Binding },
): Promise<Response> {
const storage = createStorage({
engine: new CloudflareD1StorageEngine({
format: "string",
binding: env.DATABASE,
prefix: "app",
}),
});
await storage.set("users:1", { name: "Verso" });
return Response.json(await storage.get("users:1"));
},
};import { createStorage } from "@temelj/storage";
import { CloudflareD1StorageEngine } from "@temelj/storage/cloudflare";
const storage = createStorage({
engine: new CloudflareD1StorageEngine({
format: "string",
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
apiToken: process.env.CLOUDFLARE_API_TOKEN,
databaseId: process.env.CLOUDFLARE_D1_DATABASE_ID,
}),
});R2 binding mode uses the native Workers bucket API.
import { createStorage } from "@temelj/storage";
import { CloudflareR2StorageEngine, type CloudflareR2Binding } from "@temelj/storage/cloudflare";
export default {
async fetch(_request: Request, env: { readonly BUCKET: CloudflareR2Binding }): Promise<Response> {
const storage = createStorage({
engine: new CloudflareR2StorageEngine({
binding: env.BUCKET,
prefix: "app",
}),
});
await storage.set("users:1", { name: "Verso" });
return Response.json(await storage.get("users:1"));
},
};import { createStorage } from "@temelj/storage";
import { CloudflareR2StorageEngine } from "@temelj/storage/cloudflare";
const storage = createStorage({
engine: new CloudflareR2StorageEngine({
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
apiToken: process.env.CLOUDFLARE_API_TOKEN,
bucketName: "app-storage",
}),
});libSQL Storage
libSQL storage uses the libsql package.
Without a path or URL, it creates an in-memory database.
The table must already exist; the engine never issues DDL.
import { createStorage } from "@temelj/storage";
import { LibSqlStorageEngine } from "@temelj/storage/libsql";
const storage = createStorage({
engine: new LibSqlStorageEngine({ format: "string", path: "storage.db", prefix: "app" }),
});
await storage.set("users:1", { name: "Verso" });
await storage.dispose();Postgres Storage
Postgres storage uses the postgres package. The table must already exist.
import { createStorage } from "@temelj/storage";
import { PostgresStorageEngine } from "@temelj/storage/postgres";
const storage = createStorage({
engine: new PostgresStorageEngine({
format: "string",
url: process.env.DATABASE_URL,
prefix: "app",
}),
});
await storage.set("users:1", { name: "Verso" });
await storage.dispose();MySQL Storage
MySQL storage uses mysql2/promise. The table must already exist.
import { createStorage } from "@temelj/storage";
import { MySqlStorageEngine } from "@temelj/storage/mysql";
const storage = createStorage({
engine: new MySqlStorageEngine({
format: "string",
url: process.env.DATABASE_URL,
prefix: "app",
}),
});
await storage.set("users:1", { name: "Verso" });
await storage.dispose();Relational engines expect key, value, and expires_at columns.
key must be unique, value should use the column type matching the engine generic (TEXT, numeric, or binary), and expires_at stores an optional millisecond Unix timestamp.
D1 uses the same shape with expires_at INTEGER NOT NULL and 0 for no expiration.
Applications are responsible for migrations and provisioning.
File system, S3, and R2 store bytes.
Web Storage stores strings.
In-memory and IndexedDB support exact or mixed StoredValue records.
Relational engines use the string, number, or binary representation returned by their configured column and normalize recognized binary driver containers to Uint8Array.
Web Storage
Local storage and session storage both belong to the localstorage subpath.
Use these engines in browser environments or pass a compatible storage object explicitly.
import { createStorage } from "@temelj/storage";
import { LocalStorageEngine, SessionStorageEngine } from "@temelj/storage/localstorage";
const localStorageStore = createStorage({
engine: new LocalStorageEngine({
namespace: "app",
}),
});
const sessionStorageStore = createStorage({
engine: new SessionStorageEngine({
namespace: "app",
}),
});
await localStorageStore.set("settings:theme", "dark");
await sessionStorageStore.set("sessions:current", "active");IndexedDB Storage
IndexedDB storage is for browser environments where values should be stored asynchronously in an IndexedDB object store.
import { createStorage } from "@temelj/storage";
import { IndexedDbStorageEngine } from "@temelj/storage/indexeddb";
const storage = createStorage({
engine: new IndexedDbStorageEngine({
databaseName: "app-storage",
namespace: "app",
storeName: "entries",
}),
});
await storage.set("users:1", { name: "Verso" });
await storage.dispose();Custom Storage Engines
Custom engines implement the StorageEngine interface and store encoded Uint8Array values.
createStorage handles key validation, value codecs, typed keys, batch fallbacks, try* results, and error wrapping.
import { createStorage, type StorageEngine } from "@temelj/storage";
export class MapStorageEngine implements StorageEngine {
readonly name = "map";
readonly #values = new Map<string, Uint8Array>();
async get(key: string): Promise<Uint8Array | undefined> {
return this.#values.get(key)?.slice();
}
async set(key: string, value: Uint8Array): Promise<void> {
this.#values.set(key, value.slice());
}
async delete(key: string): Promise<boolean> {
return this.#values.delete(key);
}
async keys(options?: { readonly prefix?: string }): Promise<readonly string[]> {
return [...this.#values.keys()].filter(
(key) => options?.prefix === undefined || key.startsWith(options.prefix),
);
}
async clear(options?: { readonly prefix?: string }): Promise<void> {
for (const key of await this.keys(options)) {
this.#values.delete(key);
}
}
}
const storage = createStorage({
engine: new MapStorageEngine(),
});
await storage.set("users:1", { name: "Verso" });Engines must implement get, set, delete, keys, and clear.
They can optionally implement has, getMany, setMany, deleteMany, watch, and dispose for more efficient behavior, native change notifications, or resource cleanup.
