Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

142 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

colada-db

npm CI license

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.md and 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 from pinia-colada-plugin-normalizer, which becomes its first framework adapter.

Install

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.

Quickstart

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 durable

Reads 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/.

Typed entities

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;

Normalizing a payload

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 graph

An 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.

The design

  • 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-time willCommit is 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 via loadMany (never a full scan), retained per scope so GC can't evict what a live scope uses. removeManifest releases + sweeps; hydrateScope/preload page 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. Without preload, first paint on a cold entity shows pending (the synchronous store.has check can't see disk). See docs/design/query-driven-hydration.md. (DAN-578)
  • Live filtered views, two-tier. createMatcherView keeps 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-mode verifyIntegrity guard 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). See docs/design/live-matcher-views.md. (ADR-010, DAN-606)
  • Server-authoritative sync — specified, not yet shipped. The three-method SyncAdapter contract (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 still Proposed, 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.

The agent surface (packages/mcp)

Not separately published yet. colada-db-mcp lives in this repository and is exercised by its own test suite and observe-run, but it is not on npm — npm install colada-db-mcp will 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 allowedTypes are 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_history tool (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.

Architecture decisions

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.

Development

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-end

pnpm 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.

Contributing

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.

License

MIT © Danny Devs

About

AI-first, local-first client database — normalized reactive entity store with pluggable durability engines (IndexedDB, OPFS SQLite)

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages