@mirk/store
v0.8.1
Published
Code-split key-value, collection, vector, full-text search, graph, and SQL storage ports with in-memory references and a shared-connection SQLite adapter.
Maintainers
Readme
@mirk/store
Code-split storage ports + source adapters under one namespace. Import the whole namespace, or just the specific subpath you need — the interface ports and their in-memory reference implementations are zero-native, and only the SQLite adapter references native bindings (as optional peers).
ESM-only (the package exposes import entry points; there is no CommonJS build).
Install
npm install @mirk/store
# Using the SQLite adapter (@mirk/store/sqlite)? Add its peer:
npm install better-sqlite3
# Optional: vec0 KNN acceleration (graceful exact-JS fallback without it)
npm install sqlite-vecSubpaths
| Import | What it gives you | Native deps |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
| @mirk/store | the ports + their in-memory references + toAsync / toAsyncSearch, graph contract types, cosine helpers | none |
| @mirk/store/kv | SyncStore port (key-value + collections), InMemoryKv, toAsync | none |
| @mirk/store/vector | VectorStore port, InMemoryVectorStore, cosine helpers | none |
| @mirk/store/search | SearchStore / AsyncSearchStore ports, InMemorySearchStore, toAsyncSearch, BM25-style keyword search | none |
| @mirk/store/graph | graph helpers over the collection port (neighbors, traverse, traverseFrontierBatched) plus AsyncGraphTraversal for native graph adapters | none |
| @mirk/store/sql | SQL adapter contract types | none |
| @mirk/store/coordination | SQLite-backed async keyed coordinator with leases, renewal, fencing generations, and ownership-loss checks | better-sqlite3 (peer) |
| @mirk/store/sqlite | the SQLite source adapter — one connection, .kv + .vector + .search facets | better-sqlite3 (peer), sqlite-vec (optional peer) |
Source adapters are reached only through their own subpath (e.g. /sqlite) — the root and the
port subpaths never re-export them, so importing @mirk/store, /kv, /vector, /search, or
/graph never drags a native binding into a consumer bundle.
Quickstart — zero native deps
The in-memory references implement the same ports as the backends, so you can build against them with nothing installed:
import { InMemoryKv, toAsync } from "@mirk/store/kv";
const kv = new InMemoryKv();
kv.set("user:1", { name: "Ada" });
kv.get<{ name: string }>("user:1"); // { name: "Ada" }
kv.keys("user:"); // ["user:1"]
// Lift any SyncStore to a Promise-returning API (one-way: sync ⊂ async):
const asyncKv = toAsync(kv);
await asyncKv.get("user:1");Bind logical stores to one backing without exposing physical key or collection prefixes:
import { namespaceStore } from "@mirk/store";
const projects = namespaceStore(kv, "app.projects.v1");
const workspaces = namespaceStore(kv, "app.workspaces.v1");Namespaces isolate keys and collection names while preserving the ordinary SyncStore contract.
Collections
A SyncStore is also a small document store, keyed by id:
kv.put("posts", { id: "p1", title: "Hello", pinned: true });
kv.getById("posts", "p1"); // { id: "p1", title: "Hello", pinned: true }
kv.list("posts", { where: { pinned: true }, sortBy: "title", limit: 10 });
kv.count("posts"); // 1
kv.remove("posts", "p1");Full-text search
SearchStore indexes documents by id and returns BM25-ranked keyword matches. Use text for the
single-field shorthand or fields for named columns with query-time weighting:
import { InMemorySearchStore, toAsyncSearch } from "@mirk/store/search";
const search = new InMemorySearchStore();
search.index("pages", {
id: "a",
fields: { title: "Opal guide", body: "plain body" },
});
search.index("pages", {
id: "b",
fields: { title: "plain title", body: "Opal guide" },
});
search.search("pages", "opal", { fieldWeights: { title: 4, body: 1 } }); // [a, b]
const asyncSearch = toAsyncSearch(search);
await asyncSearch.search("pages", "opal");The first indexed document fixes a collection's field schema; later documents must use the same
field names. text and fields: { text } are the same single-field schema for backwards
compatibility. Remote search backends should implement AsyncSearchStore directly; local sync
backends can be lifted with toAsyncSearch.
Graph helpers
@mirk/store/graph stores edges as ordinary collection records and traverses them through the
existing collection port. Policy stays caller-owned through StoreFilter. Remote adapters with a
real graph engine can expose AsyncGraphTraversal; traverse() and traverseFrontierBatched()
delegate to that native path only for collections where canTraverseGraph(collection) is true.
Otherwise traverseFrontierBatched() uses listWhereIn when available, then falls back to the
load-once traversal.
import { traverse } from "@mirk/store/graph";
const hits = await traverse(asyncKv, "edges", {
start: "node:a",
depth: 2,
direction: "out",
});SQLite adapter — one connection, many capabilities
SqliteAdapter opens a single better-sqlite3 database and exposes .kv (SyncStore), .vector
(VectorStore), and .search (SearchStore) facets over it:
import { SqliteAdapter } from "@mirk/store/sqlite";
// .kv and .search work immediately; vector dimensions infer on first write.
const db = new SqliteAdapter({ path: "data.db" });
db.kv.set("user:1", { name: "Ada" });
db.search.index("pages", {
id: "intro",
fields: { title: "Intro", body: "hello world" },
});
db.search.search("pages", "hello", { fieldWeights: { title: 4, body: 1 } });
const embedding = new Float32Array(768); // your real embedding here
const query = new Float32Array(768);
db.vector.upsert("docs", { id: "a", vector: embedding });
const results = db.vector.search("docs", query, { topK: 10 }); // ranked by cosine
db.transaction(() => {
db.kv.set("jobs:next", 42);
db.kv.put("audit", { id: "42", event: "allocated" });
}, "immediate");
db.close();SqliteAdapter options
| Option | Type | Notes |
| --------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| path | string | DB file path, or ":memory:". |
| db | Database | Reuse an existing better-sqlite3 connection instead of opening one. |
| dimensions | number | Optional embedding dimensionality. If omitted, inferred and persisted from the first vector upsert / upsertMany; search still requires known dimensions. |
| forceJsCosine | boolean | Pin the exact JS-cosine path even when sqlite-vec is installed (mainly for tests). |
| busyTimeoutMs | number | Bounded wait for another SQLite writer. Defaults to 30 seconds and applies to owned or caller-supplied connections. |
transaction(work, mode?) runs synchronous facet operations atomically on the adapter connection.
Modes are deferred (default), immediate, and exclusive. The callback must not perform
asynchronous or external work.
Vectors (Vector is a Float32Array) are stored as little-endian float32 BLOBs and ranked by
exact cosine. When the optional sqlite-vec peer is installed, search is transparently
accelerated by vec0 using the cosine distance metric, so rankings are identical to the JS path;
without it, the exact JS-cosine fallback runs. db.vector.meta.accelerated reports which path is
live.
Async coordination
@mirk/store/coordination exposes one cooperating-writer critical-section primitive for async
external work that cannot live inside a synchronous SQLite transaction:
import { createSqliteCoordinator } from "@mirk/store/coordination";
const coordinator = createSqliteCoordinator({
path: "roadmap/.locks/coordination.sqlite",
namespace: "roadmap-specs",
});
await coordinator.runExclusive(
"specs",
async (guard) => {
guard.assertOwned();
// Read files, write Markdown, update revision state, or run git here.
guard.assertOwned();
},
{ waitMs: 5_000, leaseMs: 2_000, renewEveryMs: 500 }
);Each successful owner receives a unique ownerToken, a monotonic fencingGeneration, an
AbortSignal, and assertOwned(). The coordinator renews the lease while the callback runs.
If the process stalls past expiry and another owner takes over, the stale guard is aborted and
assertOwned() throws CoordinationOwnershipLostError. Release and renewal only affect the
current owner token and generation, so a stale owner cannot delete or renew a successor lease.
The SQLite database stores lease metadata only. Acquisition, renewal, stale recovery, and release
use short synchronous SQLite statements; no database transaction is held while the callback awaits.
Use this to serialize cooperating writers and make ownership loss observable. It is not a
transaction over Markdown files, arbitrary filesystem writes, object stores, or git commits, and it
cannot roll back partial external side effects after a crash. Consumers must call assertOwned()
around each external mutation phase and keep their own revision or idempotency checks where those
semantics matter.
Sync by design
Embedded backends are synchronous — better-sqlite3 is synchronous, and an async-everywhere
interface would tax every local call with a Promise it doesn't need. A SyncStore lifts to an
async API via toAsync(store), and a SearchStore lifts via toAsyncSearch(search); the reverse
is impossible. Pick sync for embedded/local, and reach for async only where a remote backend
genuinely requires it.
License
Apache-2.0. See LICENSE.
