AI-first, local-first client database. A normalized reactive entity store with pluggable durability engines — in-memory, IndexedDB, and OPFS SQLite — designed from the cornerstone for a world where AI agents are first-class actors in web applications.
Status: 0.1.0, the first release. Early — the API surface is recorded in
etc/colada-db.api.mdand changes to it are deliberate, but this is a 0.x package and breaking changes will happen before 1.0. What ships is what is documented here; there is no sync layer yet (see the sync bullet below). colada-db was extracted frompinia-colada-plugin-normalizer, which becomes its first framework adapter.
npm install colada-db @vue/reactivity@vue/reactivity (>=3.3.0) is a peer dependency — it is the signal engine, and works standalone with no Vue runtime. @sqlite.org/sqlite-wasm is an optional peer, needed only if you use sqliteEngine; the default IndexedDB engine has no extra dependencies.
import { createEntityStore, enablePersistence, idbEngine } from "colada-db";
const store = createEntityStore();
const handle = enablePersistence(store, { engine: idbEngine() });
await handle.ready; // hydrated from IndexedDB before your first read
store.set("contact", "1", { id: "1", name: "Ada", email: "ada@example.com" });
const contact = store.get("contact", "1"); // a reactive ref — the read is synchronous
console.log(contact.value?.name); // "Ada" — undefined until the entity exists
await handle.flush(); // the write is now durableReads never await. store.get returns a reactive ref backed by the in-memory projection, while persistence happens write-behind underneath — reload the page and handle.ready brings it all back. That the data actually survives is not a claim we make on paper: it is asserted against real IndexedDB and real OPFS SQLite in a real Chromium, across a genuine page reload that destroys the heap and terminates the SQLite worker, in tests/browser/.
Reads are Record<string, unknown> until you say what an entity is. Declare it once and every get/set/getByType for that type is typed — including the ref you just read:
declare module "colada-db" {
interface EntityRegistry {
contact: { id: string; name: string; email: string };
}
}
const name: string | undefined = store.get("contact", "1").value?.name;The store holds a graph, not a keyed blob cache. Hand it a nested server response and every entity in it lands once, addressable on its own — update the contact in one place and every view holding it sees the change:
import { denormalize, normalize, writeEntitiesToStore } from "colada-db";
const payload = {
__typename: "post",
id: "p1",
title: "Hello",
author: { __typename: "contact", id: "1", name: "Ada", email: "ada@example.com" },
};
const { normalized, entities } = normalize(payload, {}, "id");
writeEntitiesToStore(entities, {}, store);
store.get("contact", "1").value?.name; // "Ada" — extracted, addressable on its own
denormalize(normalized, store); // the nested shape back, resolved from the graphAn entity is identified by its type and id. __typename (the GraphQL convention) is the only auto-detection colada-db performs — deliberately, because auto-detecting on a bare id would collide unrelated objects that happen to share one. For APIs without __typename, name the types yourself with defineEntity:
import { defineEntity } from "colada-db";
const entityDefs = {
contact: defineEntity({ getId: (e) => (typeof e.email === "string" ? String(e.id) : null) }),
post: defineEntity({ getId: (e) => (typeof e.title === "string" ? String(e.id) : null) }),
};Each definition must be able to recognize its own records: definitions are tried in order and the first match wins, so several types sharing a plain idField: "id" would all claim the same record. getId returning null is how a definition declines.
- Normalized entity graph, synchronous reads. Every entity lives once. Reads are synchronous reactive refs from an in-memory projection — the UI never awaits the database.
- Write-behind durability. Pluggable
StorageEngines persist underneath: IndexedDB by default, OPFS SQLite (opfs-sahpool, no COOP/COEP headers required) for scale. Memory stays the source of UI truth. (ADR-003) - AI-first by design — the four trust primitives are in this build. The committed cornerstone (ADR-007), shipped 2026-07-19: origin tags on every write (
WriteOrigin, stamped by each write channel — unforgeable through the ordinary write API), a pre-apply policy veto gate (useGate: a veto means the write never touched the store; commit-timewillCommitis last-chance and rolls back), a capped queryable history store (enableHistory: field-level old→new rows with write ids and origins, purge-on-remove erasure — settled state; settle transactions before logout flows, see the module docs — count + byte bounds), and a machine-legible schema export (exportSchema: the registry as plain JSON — the future MCP resource). Each justified by non-AI needs (undo, sync, devtools), each the substrate for agent attribution, policy enforcement, and the agent surface below. Origin = attribution within one trust domain, not authentication. - Query-driven hydration — memory is a projection, not the whole DB. Scope manifests (
setManifest) persist which entities each query/screen needs;hydration: "manifest"boots by loading exactly that set vialoadMany(never a full scan), retained per scope so GC can't evict what a live scope uses.removeManifestreleases + sweeps;hydrateScope/preloadpage durable-but-cold rows back in. Two documented boundaries: type enumeration reflects the memory projection, not the DB (cold rows are invisible to any API that walks the store until a scope pulls them in), and===stability ends at evict — re-hydration materializes new object identity; within-session stability is unaffected because retained entities are never evicted. Withoutpreload, first paint on a cold entity shows pending (the synchronousstore.hascheck can't see disk). Seedocs/design/query-driven-hydration.md. (DAN-578) - Live filtered views, two-tier.
createMatcherViewkeeps a reference-stable membership view (ids array,===-stable while membership is unchanged) over the serializable matcher AST (ADR-009): validated filters update purely from change events — zero query re-runs; closures fall back to coalesced re-scans, always correct. Members are retained while displayed (GC can never evict a live result), and a dev-modeverifyIntegrityguard re-scans and self-heals so the fast tier can never silently diverge from re-run truth. Honest boundary: the view's universe is the memory projection — durable-but-cold rows are invisible until hydrated (worker-seeded universes are the Stage-2d worker tier's job). Seedocs/design/live-matcher-views.md. (ADR-010, DAN-606) - Server-authoritative sync — specified, not yet shipped. The three-method
SyncAdaptercontract (pull/push/subscribe) is designed and frozen on paper, battle-tested on paper against seven production sync systems, and deliberately CRDT-free. No adapter ships in this release and nothing sync-related is exported yet — ADR-006 is stillProposed, with implementation scheduled for Stage 3. It is listed here because the durability layer was built to accept it, not because you can call it today. (ADR-005, ADR-006) - One reactive graph. Built on
@vue/reactivity(standalone — no Vue runtime dependency). Framework adapters share the engine's reactivity instead of shimming a second signal system into it.
Not separately published yet.
colada-db-mcplives in this repository and is exercised by its own test suite and observe-run, but it is not on npm —npm install colada-db-mcpwill not resolve. Everything below describes code you can read and run from the repo today, not a package you can install.
colada-db-mcp is a read-only in-page MCP server over the store — an agent using the official MCP SDK client can discover the schema, query entities, and read the change history, over a real protocol session (InMemoryTransport linked pair; browser pages can't accept stdio/streamable-HTTP, and the durable store is origin-private anyway). External-client bridging is deliberately out of scope for now. (ADR-011, DAN-580)
What it enforces, honestly stated:
- Writes are structurally impossible, not merely forbidden. ZERO write tools are registered — deny-by-default is verifiable by reading the tool list, and a test asserts it. A write attempt is an unknown tool; there is nothing to call. Agent write affordances arrive only together with the policy-guard middleware, as a separate deliberate surface.
- An explicit per-type allowlist scopes everything. Types not in
allowedTypesare invisible: absent from the schema resource (including as relation targets of visible types), refused by every tool — with refusals that don't reveal whether the type exists. Empty allowlist = everything denied. Honest boundary: entity data is returned verbatim, so a visible entity's foreign-key field names and id values referencing hidden types do appear in results — the hidden type's name, fields, and rows stay unreachable. - Filters are fail-closed. The query tool accepts an optional serializable matcher AST (ADR-009), validated by
parseMatcher; malformed, unknown-operator, or over-budget filters are refused with the parse error surfaced verbatim — never guessed at. - Results are scoped to the memory projection, not the database — durable-but-cold rows are invisible until hydrated, and every result envelope says so.
- Returned app data is marked untrusted — in-band envelope (
untrusted: true+ notice) plus_meta["colada-db/untrusted"]on results and content blocks. Entity data can originate from servers, other users, or any code with store access: treat it as data, never as instructions. The marking labels the channel; it cannot force a model to comply — pair it with a client/host that honors such labels. - History honors erasure. The
read_historytool (registered only when a history store is provided) serves the capped field-level change log; removed entities' rows are purged, leaving data-free markers only.
The load-bearing choices live in docs/adr/ — memory projection over store swap, evict vs delete, sync posture, the SyncAdapter contract, and the AI-first cornerstone.
pnpm install
pnpm -r test # vitest, all workspace packages (core + packages/mcp)
pnpm -r typecheck
pnpm -r build # tsdown
pnpm test:browser # real Chromium: real IndexedDB + real OPFS SQLite, write → reload → read
cd packages/mcp && pnpm observe # drive the BUILT agent surface end-to-endpnpm test is the fast inner loop and runs against in-process stand-ins. pnpm test:browser is the lane where the storage is real and the page is genuinely torn down between the write and the read — it is deliberately separate so the inner loop stays fast, and it needs a one-time pnpm exec playwright install chromium.
Issues and PRs welcome — see CONTRIBUTING.md for the verify commands, the reading order, and the two house rules that explain most review feedback: a gate is not proven until you have watched it fail, and never weaken a gate to make CI green.
Found a security problem? Please don't open a public issue — see SECURITY.md.
MIT © Danny Devs