Skip to content
Sign in
← Documentation

Developer guide

The one supported way to do each common task with FeltDB, from the first record to a managed deployment.

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 id field. Records come back with id and __version. The version starts at 1, increases on every write and never goes backwards.
  • db.query is the query. Conditions are ANDed and each takes one of eq, 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.plan says how the page was read, the same way on every runtime: accessMethod (key_range, equality_index, index_range or full_scan), recordsExamined, recordsReturned and hasMore. 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 with QUERY_LIMIT_EXCEEDED above 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 with PRECONDITION_FAILED and leaves the existing record alone. Without an id argument, a record whose id field is set is stored under that id, and otherwise an id is generated and returned.
  • insert(record, id) without requireAbsent replaces the record stored under id.
  • update(id, changes) merges. It is NOT_FOUND for 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 at version. A lost race is returned as { updated: false, currentVersion }, not thrown.
  • delete(id) is NOT_FOUND for a missing record. A record still referenced by another is refused (VALIDATION_FAILED, rule reference). 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 contract here is refused with CONTRACT_UNSUPPORTED. db.capabilities().stateContract is '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

  1. Develop against path or memory with the contract in code (sections 1–9).

  2. Publish the same contract. toManagedContract(contract, { policies }) writes the file feltdb publish --contract accepts. A state contract carries no authorization, so you supply the policies: each names a resource (collection), read and write subjects, and capabilities. A subject is authenticated, owner, member or self(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
    
  3. Run the app with FELTDB_URL, FELTDB_APPLICATION_ID and FELTDB_TOKEN set, and with contract dropped from createFeltDB.

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:

  1. POST /api/tenants {name}.
  2. POST /api/tenants/{tenant}/applications {name}.
  3. POST /api/keys {id, namespace, application_id, scopes}, once for a control key and once for a data key. namespace is the application's namespace from step 2. The scopes are listed in managed production.
  4. 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