Skip to content

Features

TalaDB v0.2 introduces on-device vector indexes — the first embedded JavaScript database to natively combine document queries with vector similarity search.

Creating a vector index

ts
// Register a vector index on any numeric-array field
await articles.createVectorIndex('embedding', { dimensions: 384 })

// Choose a metric (default: cosine)
await articles.createVectorIndex('embedding', {
  dimensions: 1536,
  metric: 'cosine', // | 'dot' | 'euclidean'
})

Existing documents are backfilled automatically. New inserts and updates maintain the index atomically alongside the document write.

ts
const queryVec = await embed('how do I reset my password?') // your on-device model

const results = await articles.findNearest('embedding', queryVec, 5)
// [{ document: Article, score: 0.94 }, { document: Article, score: 0.91 }, ...]

Results are ordered by descending similarity score. Score range depends on the metric:

  • cosine — [-1, 1], identical vectors score 1.0
  • dot — unbounded, depends on vector magnitude
  • euclidean — (0, 1], identical vectors score 1.0

Hybrid search — metadata filter + vector ranking

The killer feature. Pass a regular document filter as the fourth argument to restrict the candidate set before ranking:

ts
// Find the 5 most semantically similar english-language support articles
const results = await articles.findNearest('embedding', queryVec, 5, {
  locale: 'en',
  category: 'support',
  published: true,
})

This is the pattern cloud vector databases (Qdrant, Weaviate, Pinecone) charge for — running entirely on device, with no network latency and no data leaving the user's device.

Optional HNSW index (Node.js)

The default index is flat — an exact scan over every vector, with no approximation and no recall trade-off. On Node.js (since v0.8.3) you can opt into an approximate HNSW graph for larger corpora:

ts
await articles.createVectorIndex('embedding', {
  dimensions: 384,
  indexType: 'hnsw', // default: 'flat'
})

// The graph is built at creation time and NOT updated by later writes —
// rebuild it after bulk ingests (e.g. during an idle period):
await articles.upgradeVectorIndex('embedding')

Measured on the benchmarks page: 14.6 ms vs 188 ms flat at 50k × 384-dim vectors, with 100% recall@10 on clustered (embedding-like) data. Two caveats before reaching for it: graph construction is CPU-intensive (a one-off cost that grows quickly with collection size), and recall depends on your data's structure — measure on your own embeddings. The flat index remains the right default for most on-device corpora, and is what ships on web and React Native.

Dropping a vector index

ts
await articles.dropVectorIndex('embedding')

Pairing with on-device AI

TalaDB vector indexes are designed to work alongside client-side embedding models:

ts
import { pipeline } from '@xenova/transformers'

const embedder = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2')

async function embed(text: string): Promise<number[]> {
  const output = await embedder(text, { pooling: 'mean', normalize: true })
  return Array.from(output.data)
}

// Insert a document with its embedding
const vec = await embed(article.body)
await articles.insert({ ...article, embedding: vec })

// Search later
const results = await articles.findNearest('embedding', await embed(query), 5)

No cloud API key. No rate limit. No round-trip.

Document model with ULID IDs

Documents are schemaless key-value maps. Every document is automatically assigned a ULID — a 128-bit, time-sortable, lexicographically ordered identifier. ULIDs are safe for URLs, monotonically increasing within the same millisecond, and require no coordination between writers.

MongoDB-like query API

TalaDB exposes a filter and update DSL that mirrors the MongoDB query language:

  • Comparison: $eq, $ne, $gt, $gte, $lt, $lte
  • Membership: $in, $nin
  • Logical: $and, $or, $not
  • Existence: $exists
  • Update operators: $set, $unset, $inc, $push, $pull

The full API is fully typed — TypeScript narrows filter and update shapes to the document type passed to db.collection<T>().

Aggregation pipelines

Compute summaries inside the Rust engine instead of materialising every document in JavaScript. The pipeline mirrors MongoDB's aggregation framework and is available on every runtime:

ts
const byStatus = await orders.aggregate([
  { $match: { createdAt: { $gte: monthStart } } }, // leading $match uses an index
  { $group: { _id: '$status', total: { $sum: '$amount' }, n: { $sum: 1 } } },
  { $sort: { total: -1 } },
  { $limit: 10 },
])
  • Stages: $match, $group, $sort, $skip, $limit, $project
  • Accumulators: $sum, $count, $avg, $min, $max, $push, $addToSet, $first, $last
  • Runs as a single pass over the collection; a leading $match goes through the query planner, so indexed filters skip the full scan
  • Fully typed via AggregatePipeline<T>

See the Aggregation reference for the full API.

Secondary indexes with automatic index selection

Indexes are created per field with createIndex('fieldName'). The underlying storage is a redb B-tree keyed by [type_prefix][encoded_value][ulid]. This layout gives:

  • O(log n) point lookups via $eq or $in
  • O(log n + k) range scans via $gt, $gte, $lt, $lte
  • Correct cross-type ordering — integers, floats, strings, and booleans each carry a type prefix so they never collide in the same index

The query planner examines the filter and picks the most selective available index automatically. No hints or query annotations are needed.

Compound (multi-field) indexes

Index an ordered list of fields to serve a multi-field equality query with a single index scan:

ts
await orders.createCompoundIndex(['userId', 'status'])

// One index scan instead of a full-collection scan:
await orders.find({ userId: 'u_123', status: 'open' })

The planner uses the compound index when an $and constrains every field of the index by equality. Fields are ascending; partial-prefix and trailing-range matches, and per-field descending order, are on the roadmap. Available on Node.js and the browser; React Native support is implemented and pending on-device verification.

ACID transactions via redb

Every write is wrapped in an ACID transaction at the storage layer. Document writes and index updates happen atomically — there is no window where a document exists but its index entry is missing, or vice versa. On crash or power loss, redb recovers to the last committed state.

createIndex supports a special _fts:{field} syntax that builds an inverted token index for a string field. The $contains filter operator matches documents whose field value contains all of the supplied search terms after normalisation (lowercasing, punctuation removal, short-word filtering).

ts
await posts.createIndex('_fts:body')
const results = await posts.find({ body: { $contains: 'rust embedded database' } })

Live queries

Subscribe to a filter and receive a fresh snapshot after every matching write — no polling, no websockets:

ts
const unsub = articles.subscribe({ category: 'support' }, (docs) => {
  render(docs) // fires immediately with the current results, then on every write
})
unsub()

In the browser, writes from other tabs trigger subscriptions too (via BroadcastChannel). At the Rust level the same mechanism is exposed as collection.watch(filter)WatchHandle; multiple handles can watch the same collection with different filters, with no per-watch overhead beyond an MPSC channel. See Live Queries.

Schema migrations

openDB accepts a migrations array. TalaDB stores the current version number inside the database and runs any pending up functions in order at startup, inside a single atomic transaction. Failed migrations roll back automatically.

Encryption at rest

Pass a passphrase to openDB (Node.js and the browser) — or to TalaDBModule.initialize on React Native — and every value is transparently encrypted with AES-GCM-256 before it touches disk, on all three runtimes:

ts
const db = await openDB('myapp.db', { passphrase: userSuppliedPassphrase })
  • EncryptedBackend — a StorageBackend wrapper that encrypts every value with AES-GCM-256 before writing and decrypts on read; it wraps the file backend (Node/RN) or the OPFS backend (browser) identically
  • Keys are derived with PBKDF2-HMAC-SHA256 (600,000 iterations) from the passphrase and a random 16-byte salt stored beside the database
  • Per-write random nonces; the 16-byte GCM tag detects tampering and rejects a wrong passphrase before returning a handle

Document IDs and index keys are not encrypted (they must remain comparable for lookups) — so don't index a field whose confidentiality you rely on encryption for. See the Encryption reference for the full model, including the browser's OPFS/single-tab requirement.

OPFS-backed browser persistence

In the browser, TalaDB runs inside a Dedicated Worker per tab and persists to the Origin Private File System (OPFS) via FileSystemSyncAccessHandle — durable, origin-isolated storage without IndexedDB's overhead. Multi-tab safety comes from the Web Locks API: the first tab's worker holds an exclusive lock on the OPFS file; other tabs coordinate through a BroadcastChannel, which also powers instant cross-tab live-query updates and merges secondary-tab writes back into the primary.

The engine is memory-resident and snapshots to OPFS on a short debounce (see benchmarks for the durability trade-off this buys). When OPFS is unavailable (cross-origin iframes, older browsers), TalaDB falls back to an in-memory database seeded from an IndexedDB snapshot, so data still survives page reloads.

Platform-detecting unified package

The taladb npm package auto-detects the runtime at import time:

  • Presence of globalThis.nativeCallSyncHook → React Native JSI
  • Presence of globalThis.window + navigator → browser WASM
  • Otherwise → Node.js native module

Application code never branches on platform. Swap the runtime and the same TypeScript continues to work.

Schema validation (optional)

Pass a schema option to db.collection() to get runtime type safety on writes. Any object with a parse(data: unknown): T method works — Zod, Valibot, or a hand-rolled validator.

ts
import { z } from 'zod'

const schema = z.object({ name: z.string().min(1), age: z.number() })

// Without a schema — schemaless, no overhead (default behaviour)
const users = db.collection<User>('users')

// With a schema — validates every insert before writing
const users = db.collection<User>('users', { schema })

await users.insert({ name: 'Alice', age: 30 }) // ✓ stored
await users.insert({ name: '', age: 30 })       // ✗ throws TalaDbValidationError

insert and insertMany run the document through schema.parse() before storage. If validation fails, a TalaDbValidationError is thrown and nothing is written. Collections without a schema option have zero overhead.

See the Schema Validation reference for the full API including validateOnRead, Valibot usage, and Cloudflare Workers support.

TypeScript generics

ts
interface Product {
  _id?: string
  name: string
  price: number
  inStock: boolean
}

const products = db.collection<Product>('products')

// Filter<Product> — TypeScript validates field names and value types
await products.find({ price: { $lte: 99.99 }, inStock: true })

// Update<Product> — $set only accepts fields that exist on Product
await products.updateOne({ name: 'Widget' }, { $set: { price: 49.99 } })

Bidirectional sync

Since v0.8.4, a local TalaDB can pull remote changes and push local ones in one call — with automatic Last-Write-Wins conflict resolution and a persisted incremental cursor, so each sync only transfers what changed:

ts
import { HttpSyncAdapter } from 'taladb'

const adapter = new HttpSyncAdapter({
  endpoint: 'https://api.example.com/sync', // POST {endpoint}/push · GET {endpoint}/pull?since=
  headers: { Authorization: 'Bearer my-token' },
})

await db.sync(adapter, {})                              // push + pull, all collections
await db.sync(adapter, { direction: 'pull' })           // one direction only
await db.sync(adapter, { collections: ['notes'] })      // allow-list
await db.sync(adapter, { exclude: ['logs'] })           // deny-list

Any backend becomes a sync peer by implementing the two-method SyncAdapter interface — push(changeset) and pull(sinceMs). The reference HttpSyncAdapter ships inside the taladb package; @taladb/sync-mongodb syncs straight into a MongoDB collection with no intermediate API (server-side only — it holds a database credential). Under the hood everything is built on db.exportChanges() / db.importChanges(), which are idempotent under LWW, so replays and at-least-once transports are safe.

db.sync() runs on Node.js and in the browser (since v0.9.0), where all sync engine work happens inside the Dedicated Worker — a pass never blocks the UI. React Native support is implemented across the stack and pending on-device verification. See the Bidirectional Sync guide.

HTTP push sync

After every committed write, TalaDB fires a background HTTP POST to a configured endpoint — no infrastructure required on TalaDB's side. Works with any existing REST API, webhook receiver, or analytics pipeline.

yaml
# taladb.config.yml
sync:
  enabled: true
  endpoint: "https://api.example.com/events"
  headers:
    Authorization: "Bearer my-token"
  exclude_fields:
    - embedding   # strip large vectors from the payload

The background thread is completely detached from the write path. No transaction is ever delayed or blocked by sync. Payload shapes:

  • insert{ _taladb_event, collection, id, document, timestamp }
  • update{ _taladb_event, collection, id, changes, timestamp } (delta only — changed fields)
  • delete{ _taladb_event, collection, id, timestamp }

Retries up to 3 times with 200 / 400 / 800 ms backoff on 5xx or network errors. The taladb sync CLI command pushes the full local state for initial seeding or recovery.

See the HTTP Push Sync guide for full documentation.

CRDT multi-device sync

CrdtSyncAdapter provides conflict-free sync across multiple devices using per-field LWW-registers. Unlike HTTP Push Sync, CRDT sync is bidirectional and merge-based: two devices can write the same document independently and both changes are preserved when they sync.

rust
let adapter = CrdtSyncAdapter::new("device-alice");

// Stamp fields with per-field clocks before inserting
let fields = adapter.stamp_insert(vec![
    ("title".into(), Value::Str("Hello".into())),
    ("price".into(), Value::Int(99)),
]);
col.insert(fields)?;

// Export changes since last sync and import a peer's changes
let outgoing = adapter.export_crdt_changes(&db, &["docs"], since_ms)?;
let applied  = adapter.import_crdt_changes(&db, peer_changeset)?;

The core property: if device A writes title and device B writes price on the same document at the same time, both fields survive after sync — neither is lost. When the same field is written concurrently, the higher timestamp wins; ties are broken by node_id lexicographic order.

Array fields can optionally use grow-only set (G-Set) semantics via .with_g_set_fields(["tags"]) — elements are merged by union across replicas and can never be removed.

See the CRDT Sync guide for full documentation.

CLI dev tools

Download the pre-built taladb-cli binary for your platform from the GitHub Releases page:

bash
taladb inspect myapp.db          # show collections, document counts, index names
taladb export myapp.db           # dump all documents as JSON
taladb import myapp.db data.json # bulk-import from JSON
taladb count myapp.db users      # count documents in a collection
taladb drop myapp.db sessions    # drop a collection

Snapshot export / import

ts
// Export the entire database to a portable binary snapshot
const bytes = await db.exportSnapshot()

// Restore on another device or after clearing storage
const db2 = await Database.restoreFromSnapshot(bytes)

Snapshots use a compact binary format (TDBS magic + version + length-prefixed KV pairs) and include every table — documents and indexes — so the restored database is immediately queryable without re-indexing.