Skip to content
Sign in
← Documentation

Getting started with FeltDB

FeltDB is a durable state substrate for applications and agents. You keep

Getting started with FeltDB

FeltDB is a durable state substrate for applications and agents. You keep records in named collections; every change is committed to disk before your code is told it succeeded; and you can change several records atomically or make a write conditional on the version you read. There is no separate database to run for the embedded runtime: the store is a directory your process owns.

This page takes about five minutes. Every call below is exercised by packages/core/test/first-time-user.acceptance.test.mjs, which runs in CI, so the snippets are not aspirational.

Install

npm install @feltdb/core     # Node.js 24 or newer

Open a store

import { createFeltDB } from '@feltdb/core';

const db = createFeltDB({ namespace: 'shop', path: './shop-data' });

path is the whole decision. With it, state lives in that directory and survives the process. Without it, in Node, FeltDB still stores durably, in ./.feltdb/<namespace>, and says so in db.deployment.path. For tests that should leave nothing behind use { namespace, memory: true }; memory state is process-local and is gone when the process exits. Memory namespaces are shared across the process: two handles opened with the same namespace see the same records, so give each test its own namespace.

The other forms are { browser: true } (IndexedDB) and { server: { url, token, applicationId } } (a managed authority); see runtimes and, for a server-side app on Managed FeltDB, managed production. Options not listed in the API contract — notably the runtime, storage, replication and health keys older documentation showed — are ignored.

Create, read, query

const orders = db.collection('orders');

await orders.insert({ customer: 'ada', status: 'pending', total: 40 }, 'o-1', { requireAbsent: true });

const order = await orders.get('o-1');      // { customer, status, total, id: 'o-1', __version: 1 }
await orders.get('nope');                    // null  — absence is not an error

const { records } = await db.query({
  collection: 'orders',
  where: [{ field: 'status', eq: 'pending' }],
  orderBy: [{ field: 'total', direction: 'desc' }],
  limit: 50,                                  // required, 1–1000
});

Things worth knowing on day one:

  • Every record has a __version, starting at 1 and increasing on each write. It is how you detect that someone else changed a record. It never goes backwards, not even across restart or when a record is replaced.
  • insert(record, id) replaces a record already stored under id. If you mean create, pass { requireAbsent: true }; an existing record is then left alone and the call fails with code PRECONDITION_FAILED. Without an id, a unique one is generated.
  • update(id, changes) merges changes into the record and fails with NOT_FOUND if there is no such record. Concurrent updates to different fields all take effect.
  • Queries are bounded. limit is mandatory, results are ordered deterministically (record id is the final tie-breaker), and a query that no index covers scans the collection and says so in result.plan. Declare an index with collection.createIndex(...) for queries you run often.

Change a record only if nobody else did

const seen = await orders.get('o-1');

const result = await orders.updateIfVersion('o-1', seen.__version, { status: 'packed' });
if (result.updated) {
  console.log('now at version', result.item.__version);
} else {
  // Someone else wrote first. Nothing was changed. Read again and decide again.
  console.log('lost the race; record is at version', result.currentVersion);
}

A lost race is a value, not an exception, because it is a normal outcome of concurrency. The check and the write are one step; exactly one of two writers that read the same version wins. This is how you do a read-modify-write without losing someone else's update.

Change several records atomically

await db.transaction(async (tx) => {
  tx.collection('orders').set('o-1', { customer: 'ada', status: 'shipped', total: 40 }, { expectedVersion: 2 });
  tx.collection('stock').set('sku-1', { onHand: 2 }, { expectedVersion: 1 });
  tx.collection('audit').insert({ order: 'o-1', event: 'shipped' }, 'a-1', { requireAbsent: true });
});

All three writes commit together, or none do. If any condition fails (here: a stale expectedVersion, or a-1 already existing), the call throws an error with code PRECONDITION_FAILED and nothing was written. Pass { transactionId } as a second argument to make a retry safe: a repeat of the same transaction comes back duplicate: true and applies nothing, while the same id for a different transaction fails with IDEMPOTENCY_CONFLICT. See guarantees.

Close, reopen, and your data is there

await db.close();

const again = createFeltDB({ namespace: 'shop', path: './shop-data' });
(await again.collection('orders').get('o-1')).status;   // 'shipped', version intact

Everything acknowledged before the close — or before a crash — is present after reopening, at the same versions. What exactly "acknowledged" and "crash" cover is spelled out in guarantees.

Declare your application state (optional)

Without a declaration a collection accepts any record. To have FeltDB enforce the shape of your data -- required fields, types, references between collections, unique values -- declare it once and open the store with it:

import { createFeltDB, defineStateContract, field } from '@feltdb/core';

const contract = defineStateContract({
  name: 'shop',
  version: 1,
  collections: {
    customers: { fields: { email: field.string({ required: true }) }, unique: [['email']] },
    orders: {
      fields: {
        customer: field.reference('customers', { required: true }),
        status: field.enum(['pending', 'packed', 'shipped'], { default: 'pending' }),
        total: field.number({ required: true }),
      },
    },
  },
});

const shop = createFeltDB({ namespace: 'shop', path: './shop-data', contract });
await shop.collection('orders').insert({ customer: 'nobody', total: 5 }, 'o-9');
// throws: code VALIDATION_FAILED, error.violation.rule === 'reference'

The contract is stored with the data, so every process that opens the directory is held to it, and it changes only through db.stateContract.evolve(next, migrate). This surface is experimental; what version 1 can declare, and what it deliberately refuses, is in application state contracts.

A realistic state transition

A job moves queued → running when a worker claims it. Two workers race; one must win, and the claim and its audit record must not be separable:

const claim = (worker) => db.transaction(async (tx) => {
  tx.collection('jobs').set('job-1', { state: 'running', owner: worker }, { expectedVersion: 1 });
  tx.collection('job_events').insert({ job: 'job-1', by: worker }, `claim-${worker}`, { requireAbsent: true });
});

const outcomes = await Promise.allSettled([claim('worker-a'), claim('worker-b')]);
// exactly one is fulfilled; the other rejected with PRECONDITION_FAILED and
// left no event behind

The version fence is the lock. Nothing is held between the read and the write, so a worker that dies holds nothing that has to be cleaned up.

Handling errors

Errors thrown by FeltDB carry a stable code. Branch on error.code, not on the error's class or message (collection calls throw FeltDBServiceError; transactions throw ConditionalConflictError and its relatives; both expose code):

| code | Meaning | What to do | | --- | --- | --- | | INVALID_REQUEST | The call was malformed (bad id, record not an object) | Fix the caller; do not retry | | NOT_FOUND | update, delete or updateIfVersion on a record that does not exist | Create it or stop | | PRECONDITION_FAILED | A condition did not hold: stale expectedVersion, requireAbsent on an existing id | Re-read and decide again | | VERSION_CONFLICT | A helper that retries for you gave up because the record kept changing | Back off and retry | | IDEMPOTENCY_CONFLICT | The transactionId was already used for a different transaction; nothing was applied | Choose a new id for a new transaction | | COMMIT_OUTCOME_UNKNOWN | The write may or may not have happened (remote runtimes) | Retry with the same transactionId |

get never throws for a missing record: it returns null.

Runtimes

| | Node file (path) | Memory (memory: true) | Browser (browser: true) | Managed (server) | | --- | --- | --- | --- | --- | | Survives process exit | Yes | No | Yes (IndexedDB) | Yes | | Atomic transactions, version fences | Yes | Yes | Yes | Yes | | Several processes on one store | With access: 'shared' (writer lock); exclusive by default | No | One origin | Yes |

The contract is the same on each; where a runtime cannot honor an operation it throws CONTRACT_UNSUPPORTED instead of approximating it.

Where next

  • Guarantees and limits: what is durable, what is atomic, what is not promised.
  • Managed FeltDB in production: project, server-side credentials, every remote error code, health checks, and what is proven on the managed service today.
  • API contract: the authoritative list of stable and experimental surfaces.
  • Agent state: checkpoints, handoffs and resumable work, built from the primitives on this page (experimental).