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 underid. If you mean create, pass{ requireAbsent: true }; an existing record is then left alone and the call fails with codePRECONDITION_FAILED. Without anid, a unique one is generated.update(id, changes)mergeschangesinto the record and fails withNOT_FOUNDif there is no such record. Concurrent updates to different fields all take effect.- Queries are bounded.
limitis 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 inresult.plan. Declare an index withcollection.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).