Developer guide
This page gives one way to do each common task: the way FeltDB supports, tests on every runtime, and keeps compatible. Other calls exist for older code and for tooling. Each section links the reference that defines the behavior exactly. If something here disagrees with the code, that is a bug in one of them.
Every js block on this page whose comment says // guide runs, in order, in
e2e/developer-guide.test.mjs in CI. For a
complete application built only from this path, see
examples/field-service.
| Task | The call |
| --- | --- |
| Create a database | createFeltDB({ namespace, path \| memory \| browser \| server, contract }) |
| Declare the application's state | defineStateContract, field.* |
| Read a record and query a collection | collection.get(id), db.query({ collection, where, orderBy, limit }) |
| Page through a large result | db.query({ ..., cursor }) until nextCursor is absent |
| Create, update and delete | insert(record, id, { requireAbsent: true }), update(id, changes), delete(id) |
| Change several records atomically | db.transaction(tx => ...) with expectedVersion / requireAbsent |
| React to changes | collection.subscribe(fn); in React, useCollection |
| Handle refusals | error.code, error.violation, error.failure |
| Evolve the contract | db.stateContract.evolve(next, migrate); managed: feltdb publish --contract |
| Connect to managed FeltDB | createFeltDB({ server: { url, token, applicationId } }) |
| Move from local to deployed | the same contract, published; the same code, new environment |
| Back up and restore | feltdb-server backup create \| verify \| restore |
1. Create a database
// guide
import { createFeltDB, defineStateContract, field, FeltDBServiceError } from '@feltdb/core';
const db = createFeltDB({ namespace: 'repairs', memory: true, contract: defineStateContract(repairs()) });
Choose the runtime with one option:
| Option | Where state lives | Use it for |
| --- | --- | --- |
| path: './data' | a directory this process owns (Node); access: 'shared' lets several processes write | local development, single-host services |
| memory: true | this process, gone when it exits | tests (give each test its own namespace) |
| browser: true | IndexedDB | in-browser apps that keep state on the device |
| server: { url, token, applicationId } | a FeltDB server | production; see section 10 |
Leaving all of them out in Node stores durably in ./.feltdb/<namespace>, and
db.deployment.path says where. The operations below are the same on every
runtime. Where a runtime cannot do something it refuses with
CONTRACT_UNSUPPORTED rather than approximating it. db.capabilities() says
what the open runtime supports.
2. Declare the application's state
A state contract declares the collections, their fields, references between them, unique values and indexes. Every write is checked against it inside the commit. Without a contract a collection accepts any record.
// guide
function repairs() {
return {
name: 'repairs',
version: 1,
collections: {
customers: {
fields: { name: field.string({ required: true }), email: field.string({ required: true }) },
unique: [['email']],
},
orders: {
fields: {
customerId: field.reference('customers', { required: true }),
status: field.enum(['open', 'assigned', 'done'], { default: 'open' }),
dueOn: field.string({ required: true }),
technician: field.string(),
},
indexes: [{ name: 'orders_by_status', fields: ['status', 'dueOn'] }],
},
order_events: {
fields: { orderId: field.reference('orders', { required: true }), technician: field.string({ required: true }) },
},
},
};
}
defineStateContract(repairs()) checks the declaration and returns the
contract. Keeping the declaration as a plain object lets the next version build
on it (section 9). A contract is closed: writing a
collection it does not declare is UNKNOWN_COLLECTION. The full grammar (types, default, nullable,
identifiers, what version 1 refuses) is in
application state contracts.
The contract is stored with the data, so every process that opens the store is
held to it.
3. Read and query
// guide
const customers = db.collection('customers');
const orders = db.collection('orders');
await customers.insert({ name: 'Ada', email: 'ada@example.com' }, 'c-1', { requireAbsent: true });
for (let n = 1; n <= 120; n += 1) {
await orders.insert({ customerId: 'c-1', dueOn: `2026-12-${String(1 + (n % 28)).padStart(2, '0')}` }, `o-${n}`, { requireAbsent: true });
}
const order = await orders.get('o-1'); // { customerId, status: 'open', dueOn, id: 'o-1', __version: 1 }
console.assert(await orders.get('missing') === null, 'absence is null, not an error');
const page = await db.query({
collection: 'orders',
where: [{ field: 'status', eq: 'open' }],
orderBy: [{ field: 'dueOn', direction: 'asc' }],
limit: 50, // required, 1-1000
});
console.assert(page.records.length === 50 && page.nextCursor, 'a full page, and more to come');
console.assert(page.plan.accessMethod === 'index_range', 'the index served the order');
- A record's id is its
idfield. Records come back withidand__version. The version starts at 1, increases on every write and never goes backwards. db.queryis the query. Conditions are ANDed and each takes one ofeq,neq,lt,lte,gt,gte. The record id is the final tie-breaker, so the order is total and the same on every runtime.page.plansays how the page was read, the same way on every runtime:accessMethod(key_range,equality_index,index_rangeorfull_scan),recordsExamined,recordsReturnedandhasMore. A query no index serves scans, and says so.collection.find({ field: value })is a shortcut for an equality match that you know is small. It returns every match, or refuses withQUERY_LIMIT_EXCEEDEDabove 1000. It never returns part of the result.
Reference: bounded queries.
4. Page through a large result
// guide
async function* allOpenOrders() {
let cursor;
do {
const page = await db.query({
collection: 'orders',
where: [{ field: 'status', eq: 'open' }],
orderBy: [{ field: 'dueOn', direction: 'asc' }],
limit: 50,
...(cursor ? { cursor } : {}),
});
yield* page.records;
cursor = page.nextCursor;
} while (cursor);
}
let seen = 0;
for await (const open of allOpenOrders()) seen += 1;
console.assert(seen === 120, 'every record exactly once');
A page with a nextCursor is full and more records follow. A page without one
is the end (exhausted: true). A cursor is a position in the order, not a
snapshot: records written ahead of it appear on later pages, and none is
returned twice. A cursor belongs to its query, and reusing it with a different
query is INVALID_CURSOR.
5. Create, update and delete
// guide
await orders.insert({ customerId: 'c-1', dueOn: '2027-01-05' }, 'o-500', { requireAbsent: true }); // create
await orders.update('o-500', { technician: 'sam' }); // merge into the record
await orders.insert({ customerId: 'c-1', dueOn: '2027-01-06' }, 'o-500'); // replace it whole
await orders.delete('o-500');
- Create with an id and
{ requireAbsent: true }. If the id is taken, the call fails withPRECONDITION_FAILEDand leaves the existing record alone. Without an id argument, a record whoseidfield is set is stored under that id, and otherwise an id is generated and returned. insert(record, id)withoutrequireAbsentreplaces the record stored underid.update(id, changes)merges. It isNOT_FOUNDfor a record that does not exist, and concurrent updates of different fields all take effect.- Conditional update.
updateIfVersion(id, version, changes)writes only if the record is still atversion. A lost race is returned as{ updated: false, currentVersion }, not thrown. delete(id)isNOT_FOUNDfor a missing record. A record still referenced by another is refused (VALIDATION_FAILED, rulereference). Delete or repoint the referrers in the same transaction.
6. Change several records atomically
// guide
async function assign(orderId, technician) {
const current = await orders.get(orderId);
const { id, __version, ...record } = current;
await db.transaction((tx) => {
tx.collection('orders').set(orderId, { ...record, status: 'assigned', technician }, { expectedVersion: __version });
tx.collection('order_events').insert({ orderId, technician }, `${orderId}-assigned`, { requireAbsent: true });
});
}
// Two technicians claim the same order at once: exactly one wins.
const claims = await Promise.allSettled([assign('o-3', 'sam'), assign('o-3', 'kim')]);
const loser = claims.find((claim) => claim.status === 'rejected').reason;
console.assert(claims.filter((claim) => claim.status === 'fulfilled').length === 1);
console.assert(loser.code === 'PRECONDITION_FAILED', 'the loser wrote nothing and can re-read');
Inside a transaction, set(id, record, { expectedVersion }) writes the whole
record, but only if it is still at that version. insert(record, id, { requireAbsent: true }) creates. delete(id) removes. Everything commits
together or nothing does. If any condition fails, the call throws
PRECONDITION_FAILED with error.failure (predicate, collection, id,
expected, actual) and writes nothing. Of two writers that read the same
version, exactly one wins. Pass { transactionId } as the second argument to
make a retry safe: a repeat comes back duplicate: true and applies nothing.
Reference: transactions.
7. React to changes
// guide
const deliveries = [];
const stop = orders.subscribe((all) => deliveries.push(all.filter((o) => o.status === 'open').length));
await orders.update('o-2', { status: 'done' });
await new Promise((resolve) => setTimeout(resolve, 20));
stop();
console.assert(deliveries.at(-1) === 118, 'the count after the change');
collection.subscribe(fn) calls fn with the collection's records after each
committed change. The first call comes with the first change, not when you
subscribe, so read once yourself to start. It returns the unsubscribe
function. Each delivery is the whole collection, so use it for views of
collections that stay small. For a large collection, subscribe and re-run your
bounded db.query in the callback. What reaches a subscriber depends on the
runtime:
| Runtime | Sees writes from |
| --- | --- |
| memory, file | this process |
| browser | this origin's tabs |
| server | every client of the application (needs the events:read scope) |
In React, use useCollection from @feltdb/core/react. It reads with
find(query), re-reads on each change, and releases the subscription on
unmount. Its writes reject with FeltDB's own error:
import { useCollection } from '@feltdb/core/react';
function OpenOrders({ db }) {
const { data, loading, error, update } = useCollection(db.collection('orders'), { query: { status: 'open' } });
if (loading) return <p>Loading…</p>;
if (error) return <p>{error.code}: {error.message}</p>;
return data.map((order) => (
<button key={order.id} onClick={() => update(order.id, { status: 'done' }).catch(() => {})}>{order.dueOn}</button>
));
}
useCollection is the supported hook. useFeltDB, useLegacyFeltDB and
withFeltDB are deprecated. Its query has the same 1000-record bound as
find. For longer lists, page with db.query in your component.
8. Handle refusals
// guide
const refusal = await customers.insert({ name: 'Eve', email: 'ada@example.com' }, 'c-2', { requireAbsent: true }).catch((e) => e);
console.assert(refusal instanceof FeltDBServiceError && refusal.code === 'CONFLICT' && refusal.violation.rule === 'unique');
const missing = await orders.insert({ dueOn: '2027-01-01' }, 'o-x').catch((e) => e);
console.assert(missing.code === 'VALIDATION_FAILED' && missing.violation.rule === 'required');
Branch on error.code, which is the same on every runtime:
| code | Meaning | What to do |
| --- | --- | --- |
| VALIDATION_FAILED | The record breaks the contract. violation.rule is required, type, reference, unknown_field or identifier, with collection and field | Fix the input; retrying will not help |
| CONFLICT | A unique constraint (violation.rule === 'unique', violation.index) | Choose another value |
| UNKNOWN_COLLECTION | The contract does not declare that collection | Declare it, or fix the name |
| SCHEMA_MISMATCH | The store holds a newer contract than the one this process declared | Upgrade the code, or evolve |
| PRECONDITION_FAILED | A version or absence condition did not hold (error.failure) | Read again and decide again |
| NOT_FOUND | update or delete of a missing record | Create it, or stop |
| INVALID_REQUEST / INVALID_QUERY / INVALID_CURSOR | A malformed call | Fix the caller |
| QUERY_LIMIT_EXCEEDED | find would return more than 1000 records | Use db.query with a cursor |
| IDEMPOTENCY_CONFLICT | A transactionId reused for a different transaction | Use a new id |
| COMMIT_OUTCOME_UNKNOWN | A remote write's answer was lost | Retry with the same transactionId |
| CONTRACT_UNSUPPORTED | This runtime cannot do the operation | See db.capabilities() |
get of a missing record returns null. Every refusal is a
FeltDBServiceError (transaction refusals are its subclasses), carrying code,
plus status and requestId when a server answered. The remote error codes
are listed in full in managed production.
9. Evolve the contract
// guide
const v1 = repairs();
const v2 = defineStateContract({
...v1,
version: 2,
collections: {
...v1.collections,
orders: { ...v1.collections.orders, fields: { ...v1.collections.orders.fields, priority: field.enum(['low', 'high'], { default: 'low' }) } },
},
});
await db.stateContract.evolve(v2);
console.assert((await db.stateContract.active()).version === 2);
evolve(next, migrate?) installs a higher version in one transaction.
migrate(tx) stages any data changes. Every governed record is re-validated
first, and if one would violate the new contract the whole evolution is
refused and the old contract stays active. A process that still declares the
old version can read, but its writes are SCHEMA_MISMATCH. On managed FeltDB
you publish the new version instead (next section), and the authority decides
what needs approval.
10. Connect to managed FeltDB
const db = createFeltDB({
namespace: 'repairs',
server: {
url: process.env.FELTDB_URL, // https://api.feltdb.com
token: process.env.FELTDB_TOKEN, // the application's data key: server-side only
applicationId: process.env.FELTDB_APPLICATION_ID,
},
});
The code after this line does not change. Two things do:
- The contract is published, not passed. The server enforces the contract
promoted to the application, and passing
contracthere is refused withCONTRACT_UNSUPPORTED.db.capabilities().stateContractis'requires-publish'. - The data key never reaches a browser. Browser code talks to your server, and your server talks to FeltDB.
Creating the project, the keys and their scopes, every remote error, health checks and what is proven about the managed service today are covered in managed production.
11. Move from local development to deployment
-
Develop against
pathormemorywith the contract in code (sections 1–9). -
Publish the same contract.
toManagedContract(contract, { policies })writes the filefeltdb publish --contractaccepts. A state contract carries no authorization, so you supply the policies: each names aresource(collection),readandwritesubjects, andcapabilities. A subject isauthenticated,owner,memberorself(field), and several can be combined with|. Anything else,'true'included, is refused when you publish.import { writeFileSync } from 'node:fs'; import { toManagedContract } from '@feltdb/core'; const file = toManagedContract(defineStateContract(repairs()), { policies: ['customers', 'orders'].map((name) => ({ name: `${name}-app`, resource: name, read: 'authenticated', write: 'authenticated', capabilities: ['state:read', 'state:write'], })), }); writeFileSync('feltdb.contract.json', JSON.stringify(file, null, 2));npx feltdb publish --contract feltdb.contract.json --yes # control key, from CI or your machine -
Run the app with
FELTDB_URL,FELTDB_APPLICATION_IDandFELTDB_TOKENset, and withcontractdropped fromcreateFeltDB.
Run a FeltDB server on your machine, configured as managed is, to test step
3 before deploying. examples/field-service/test/managed.test.mjs performs
exactly these steps:
cargo build -p feltdb-server # or: cargo install feltdb-server
export FELTDB_MASTER_KEY=development-only-key # required; never generated for you
feltdb-server keys create --keys ./keys.json --name operator --scope '*' --namespace '*' --secret-file ./operator.secret
feltdb-server --port 7700 --auth --data ./state.log --keys ./keys.json
Then, with the operator key as the bearer token:
POST /api/tenants {name}.POST /api/tenants/{tenant}/applications {name}.POST /api/keys {id, namespace, application_id, scopes}, once for a control key and once for a data key.namespaceis the application'snamespacefrom step 2. The scopes are listed in managed production.- Publish with
--approve-destructive-changes. The first contract on an application changes its authorization boundary, so it needs that flag.
12. Back up and restore
A server: feltdb-server backup create --output <dir>, then
backup verify <dir>, and backup restore to restore. The bundle is verified
before create reports success, and an online backup is write-consistent.
See backup and restore and
durability. Managed
FeltDB backs up the service; ask your operator for the recovery point.
An embedded store (path) is a directory. FeltDB has no backup command
for it in the TypeScript SDK yet. Copy the directory only while no process has
it open: db.close() first. Restoring is opening the copy. A copy taken while
the store is open is not guaranteed to be consistent.
Where next
- Getting started: the same calls, step by step.
- Guarantees and limits: what is durable and atomic, and what is not promised.
- Public API contract: every surface, with its stability (Stable, Experimental, Deprecated, Internal).