Managed FeltDB in production
The path from "it works on my laptop" to a server-side application whose
durable state lives in Managed FeltDB, and what is and is not proven about
that path today. Every step below is exercised by the example in
examples/managed-feltkit-app and its test,
which runs the app and FeltDB as two separate processes.
Read this first. The managed service at
api.feltdb.comcurrently runs release 0.11.9 (commit9f2354e, built 2026-09-28), not the release this page ships with. Its measured behaviour is recorded inartifacts/managed-contract/managed.jsonandartifacts/managed-contract/example-managed.json. Recovery after a restart of the managed service is not verified. See Guarantees: proven, conditional, unsupported.
1. Create or select a managed project
A managed project is an account (your sign-in), a tenant (workspace)
the account owns, an application inside the tenant, and an environment
of that application (production unless you create another). Data lives in
one application environment, under the contract promoted to it: the
collections, fields and policies the authority enforces. An application with
no promoted contract cannot be read or written.
With the CLI (the tested self-serve path). In a new project:
npx create-feltdb my-app --managed # asks: sign in, or create a feltdb.com account
or in an existing project, feltdb init-managed. Either one first asks
"Do you already have a feltdb.com account? [y/N]": y signs in to it; Enter
creates a new one and asks for the password twice. Passwords are at least 12
characters. Non-interactively, set FELTDB_MANAGED_EMAIL, FELTDB_MANAGED_PASSWORD
and FELTDB_MANAGED_MODE=signin or signup (or pass --signin / --signup); a
script that names no mode creates the account, or signs in to it if it already
exists, as before.
The CLI says which happened (✓ Signed in to feltdb.com as you@example.com or
✓ Created your feltdb.com account you@example.com), then uses your first tenant
(or creates one) and the application named after the project (or creates it),
saying which (Using workspace "…" / Created workspace "…", likewise for the
application). It mints two keys bound to that application (a control key and a
data key, below), writes .env.local (mode 600, do not commit it) and
.feltdb/managed.json, then publishes feltdb.flow and promotes it to
production. It ends with where to find the account on the web: open
https://feltdb.com/login and sign in with the same e-mail and password (not
"Continue with GitHub" unless you have linked GitHub to the account), and the
application at https://<tenant>.feltdb.com/<app>.
When the account service refuses, the CLI says what to do:
| Code | Meaning, and what to do |
| --- | --- |
| ACCOUNT_EXISTS | You chose to create an account for an e-mail that has one: run again and choose to sign in. |
| INVALID_CREDENTIALS | The e-mail and password don't match an account. If you're new, choose to create one; if you signed up with GitHub on feltdb.com, link GitHub there first or create a password account. |
| PASSWORD_TOO_SHORT | Passwords are at least 12 characters. |
| ACCOUNT_CLAIM_REQUIRED | The account was created before passwords were stored (FeltDB < 0.15.0); FeltDB support must claim it before you can sign in. |
Later contract changes go out with feltdb publish --yes
(--environment <name> for another environment, --dry-run to preview); see
application publishing.
A project that brings its own contract (a FeltKit application generates
feltdb.contract.json with feltkit contract): the same setup, with the contract file
in place of feltdb.flow and a least-privilege runtime key:
feltdb init-managed --contract feltdb.contract.json --runtime-scopes minimal --yes
feltdb publish --contract feltdb.contract.json --yes # after a contract change
feltdb managed keys create --name my-app-runtime-2 --scopes minimal # rotate: new key in .env.local
feltdb managed keys revoke my-app-runtime # then revoke the old one
See application publishing.
After a promotion, each running client's next request is refused once with
REVISION_NOT_ACTIVE (nothing applied) and the one after runs under the new contract. See
running clients across a promotion.
With the API. The same steps, as the CLI performs them:
| Step | Request | Credential |
| --- | --- | --- |
| Sign in / sign up | POST https://www.feltdb.com/api/auth {email, password, mode} with mode signin, signup or auto (the default: create, else sign in); 201 {created: true, …} for a new account, 200 {created: false, …} for a sign-in | — (returns a feltdb_session cookie) |
| Create tenant | POST /api/tenants {name} | session cookie |
| Create application | POST /api/tenants/{tenant}/applications {name} | session cookie |
| Mint a key | POST /api/keys {id, namespace, application_id, scopes} | session cookie |
| Draft the contract | POST https://api.feltdb.com/api/applications/{app}/drafts, then PUT …/drafts/{draft} with If-Version and {manifest} | control key |
| Validate, commit | POST …/drafts/{draft}/validate, POST …/drafts/{draft}/commit | control key |
| Promote | POST …/revisions/{revision}/promote {environment, reason} | control key |
| Verify | GET /v1/application?application_id={app}&environment={env} returns the promoted revision_id | data key |
examples/managed-feltkit-app/scripts/publish-contract.mjs
is the draft → validate → commit → promote → verify sequence as one short script, for a
contract kept as JSON (collections and policies, the same manifest format
feltdb publish produces from feltdb.flow). Promotion is atomic: a failed
validation leaves the previous revision active.
2. Configure the app: endpoint and server-side credentials
| Variable | Value |
| --- | --- |
| FELTDB_URL | https://api.feltdb.com |
| FELTDB_APPLICATION_ID | your application id (app_…) |
| FELTDB_ENVIRONMENT | production (default) or the environment you promoted to |
| FELTDB_TOKEN | the application's data key — a secret |
| FELTDB_NAMESPACE | optional label for the client handle |
Set them only in the server process environment (your host's secret store,
a .env file the server reads, a container secret). A FeltDB key must never
reach a browser: never put it in a variable a bundler inlines into client code
(VITE_*, NEXT_PUBLIC_*, PUBLIC_*, REACT_APP_*, EXPO_PUBLIC_*), in
client-side config, in a mobile app, or in a log. Browser code talks to your
server, which talks to FeltDB.
Key types:
| Key | Holds | Scopes | Where it lives |
| --- | --- | --- | --- |
| Data key (runtime) | read/write state of one application | minimal: application:read, application:revision:read, state:read, state:write; add events:read only if you subscribe to changes | the app server's FELTDB_TOKEN |
| Control key (deploy) | publish and promote contracts of one application | application:read, application:write, application:revision:create, application:revision:read, application:revision:promote, application:environment:read, application:environment:write | CI or the developer's machine (FELTDB_MANAGED_CONTROL_API_KEY); never the running app |
| Operator key | the whole authority (created on the host with feltdb-server keys create) | wide | the operator only; never an application |
Mint every key bound to one application_id with the narrowest scopes that
work. The data key create-feltdb mints also carries events:read,
workflows:run, agents:run and capabilities:run. If the app does not use
them, mint a narrower one: feltdb init-managed --contract … --runtime-scopes minimal,
or feltdb managed keys create --scopes minimal. Never use an operator or wildcard key in an app. The example's test mints its data key with exactly the four minimal
scopes above, which is how that list is known to be sufficient: with
application:revision:read missing, every read and write is FORBIDDEN.
3. Connect
import { createFeltDB } from '@feltdb/core';
const db = createFeltDB({
namespace: 'my-app',
server: {
url: process.env.FELTDB_URL,
token: process.env.FELTDB_TOKEN,
applicationId: process.env.FELTDB_APPLICATION_ID,
environment: process.env.FELTDB_ENVIRONMENT ?? 'production',
requestTimeoutMs: 10_000, // default 30 s
},
});
createFeltDB({ mode: 'remote', url, token, applicationId, environment }) is
the same configuration; with no options at all, createFeltDB() reads
FELTDB_URL, FELTDB_TOKEN, FELTDB_APPLICATION_ID and FELTDB_ENVIRONMENT
itself. With applicationId set the client uses the application API
(/v1/application, /v1/query, /v1/transactions) under the promoted
contract. The remote client keeps no local copy and never falls back to a
local store: if the authority cannot answer, the call fails with a typed error.
An environment that sets FELTDB_URL together with a local path
(FELTDB_PATH, FELTDB_DATA_DIR) is refused as contradictory; the example app
also refuses to start when either path variable is set.
4. Read, write, and transact with guards
The API is the one in getting started; what matters in production is that every decision you make from a read is guarded:
const accounts = db.collection('accounts');
const [from, to] = await Promise.all([accounts.get('a-1'), accounts.get('a-2')]);
await db.transaction(tx => {
const c = tx.collection('accounts');
c.set('a-1', { owner: from.owner, balance: from.balance - 30 }, { expectedVersion: from.__version });
c.set('a-2', { owner: to.owner, balance: to.balance + 30 }, { expectedVersion: to.__version });
}, { transactionId: `transfer-${requestId}` });
- The guards are evaluated by the authority, inside the commit: of two transfers built on the same versions, exactly one commits; a stale guard on either record refuses the whole transaction.
insert(value, id, { requireAbsent: true })is a create that fails if the record exists.- A
transactionIdyou choose makes the transaction safe to resend: the same id with the same operations returns the original commit withduplicate: trueand applies nothing. Derive it from something the caller sends (anIdempotency-Keyheader) when the caller may retry. - Every collection and field you write must be declared in the promoted
contract. Reads are bounded:
db.queryneeds alimit(1–1000).
5. Handle every failure
Branch on error.code. Every remote failure is a FeltDBServiceError (or a
subclass) carrying code, status when the authority answered, requestId
(req_…, from the authority's answer) and, for a refused guard, failure.
Messages say what to fix; do not parse them.
| code | HTTP | Means | Do |
| --- | --- | --- | --- |
| PRECONDITION_FAILED | 409 | A guard did not hold (ConditionalConflictError). Nothing was written. error.failure = { predicate, collection, id, key, expected, actual }, predicate one of version, epoch, lease, present, missing, fence | Re-read and decide again. Never retry the same premise. |
| IDEMPOTENCY_CONFLICT | 409 | This transactionId was already committed with different operations (TransactionIdConflictError). Nothing applied | Use a new id for a new transaction; to the caller, "this key was already used". |
| IDEMPOTENCY_UNVERIFIABLE | 409 | The id exists but its recorded identity cannot be compared | Do not assume either way; reconcile from state. |
| COMMIT_OUTCOME_UNKNOWN | — | No answer arrived for a write (timeout, connection drop, server killed). It may have committed | Retry with the same transactionId and the same operations, and only that. The answer is the original commit (duplicate: true), a fresh commit, or a refusal — never a second effect. |
| DURABILITY_FAILURE | 503 | The authority could not make the commit durable; outcome unknown | Same as COMMIT_OUTCOME_UNKNOWN. Never treat it as a refusal. |
| SERVICE_UNAVAILABLE | 503 or — | A read could not reach the authority, or it is not serving. Nothing changed | Back off and retry the read. |
| SERVICE_OVERLOADED | 503 + Retry-After: 1 | Admission or deadline shed the request | Honour Retry-After; retry reads, and writes only with the same transactionId. |
| AUTHENTICATION_REQUIRED | 401 | No credential, or one the authority does not accept (wrong, expired, revoked) | Fix FELTDB_TOKEN. Retrying cannot help. |
| FORBIDDEN | 403 | The key is valid but not for this application, environment or scope — and a write to a collection the contract does not declare is also refused this way | Use a data key minted for this application with the scopes above; check the contract. |
| AUTHORITY_SCOPE_MISMATCH | 403 | authorityScope names a tenant that does not own the application | Fix or omit authorityScope. |
| NOT_FOUND | 404 | application not found (wrong FELTDB_APPLICATION_ID) or application environment has no active contract (nothing promoted to FELTDB_ENVIRONMENT) | Fix the setting, or publish and promote. |
| REVISION_NOT_ACTIVE | 409 | The request named a revision that is not the one promoted to its environment (a promotion superseded it while the client was running). Nothing was read or written; the client has forgotten that revision | Do not resend it automatically. The request was built for the previous contract; your code decides whether it still makes sense, and if so sends it again, under the active contract. Expect it once per promotion per running client. |
| UNKNOWN_COLLECTION | 404 | A read names a collection the promoted contract does not declare | Add it to the contract and promote. |
| VALIDATION_FAILED | 422 | E.g. record contains an unknown field | Declare the field, or stop writing it. |
There is no UNAUTHENTICATED code; the authority's code for a missing or
refused credential is AUTHENTICATION_REQUIRED. The example maps these to its
own HTTP answers in one function,
src/feltdb-errors.mjs:
409 with the failure body for a guard, 409 for an idempotency conflict, 503
with Retry-After for unavailable and uncertain outcomes, 401/403 propagated
with source: "feltdb", 500 for a misconfigured application id, environment
or contract. Its commitOnce retries an uncertain or unavailable commit with
the same transaction id, a bounded number of times.
6. Verify persistence after the app restarts
The app holds no state, so restarting it changes nothing that matters. To see
it: write, kill the app process (SIGKILL), start it again, read back, and
resend an idempotent request with its original key — it answers as a replay
(duplicate: true) rather than a second write. The example's test does
exactly this against a separate server and against the live fixture (step
E15), and checks that the app created no file anywhere under its working
directory or HOME (E17).
7. Diagnose connection and service failures
| Endpoint | Auth | Answers |
| --- | --- | --- |
| GET /health | none | the process is up, and which build: release_id, git_commit, build_timestamp, deployment_id |
| GET /ready | none | 200 only when the authority can serve: storage writable, clock healthy (status: "ready"); 503 otherwise |
| GET /v1/diagnostics | any valid key | engine version, server commit, protocol version, recovery state, uptime |
| GET /v1/application?application_id=…&environment=… | data key | the promoted revision_id your key resolves — the quickest check that URL, key, application and environment agree |
/health answering while /ready does not means the process is up but cannot
serve. A failing /v1/application with a healthy /ready is a credential or
configuration problem; the error's code and message say which. Quote
error.requestId when reporting a failed request; on the managed service the
fly-request-id response header identifies the request at the edge.
An app's own health endpoint should test both halves honestly: the
example's /healthz returns 200 only when /ready answers and an
authenticated bounded read (db.query({ collection, limit: 1 })) succeeds, and
otherwise 503 with the reason (reachable: false, or data.code: "AUTHENTICATION_REQUIRED"). Never answer "healthy" from a cached value.
db.health() is not supported on the remote runtime today (it throws); use
your own probe as above.
8. Run the app and the database separately
In production the app and FeltDB are separate processes on separate hosts; the
app reaches FeltDB only over HTTPS and survives FeltDB being briefly
unavailable — reads fail with SERVICE_UNAVAILABLE, health reports 503, and
the next request after recovery succeeds without restarting the app. The
example's test proves this shape locally: it starts feltdb-server (auth on)
as one process, provisions a tenant, application and keys through the control
plane, publishes the contract with the example's script, starts the app as a
second process with only environment variables and a temporary working
directory, and then SIGKILLs and restarts each of them in turn (E15, E16).
npm ci && npm run build:core && cargo build -p feltdb-server
npm run test:example-managed-app
Local development vs managed production
| | Local development (embedded file store) | Managed production |
| --- | --- | --- |
| Open | createFeltDB({ namespace, path }) | createFeltDB({ server: { url, token, applicationId, environment } }) |
| Where state lives | a directory your process owns (.feltdb/<namespace> by default) | the managed authority; nothing on the app host |
| Credentials | none | an application data key, server-side only |
| Schema | free-form collections and fields | collections, fields and policies declared in the promoted contract |
| Who can open it | one process (exclusive lock) unless access: 'shared' | any number of app processes |
| Transactions, guards, transactionId | same API and semantics | same API and semantics, evaluated by the authority |
| COMMIT_OUTCOME_UNKNOWN | does not occur | a normal outcome; retry with the same id |
| Indexes | collection.createIndex(...) | declared in the contract |
| Health | n/a | /health, /ready, your app's own probe |
| Restart recovery | proven (process kill) | not verified on the managed service |
| Backup and restore | feltdb-server backup CLI on your files | operator-only; no developer-facing restore API |
| Running release | the @feltdb/core you installed | whatever is deployed (today 0.11.9) |
Guarantees: proven, conditional, unsupported
The managed contract suite runs the same
cases against a separate feltdb-server from this tree (--target server) and
the live service (--target managed). The live results were taken against the
deployed 0.11.9 build with the suite's own client of that time
(artifacts/managed-contract/managed.json, 2026-10-28); the example app was run
once more against the same live fixture with this tree's client
(artifacts/managed-contract/example-managed.json, steps E01–E18).
Proven on the live managed service (0.11.9).
- C01 authenticated reads, writes and transactions; C03 a wrong token is refused with a stable code; C05 tenant 2 cannot read tenant 1.
- C06 of 8 concurrent transactions guarded on the same version exactly one commits; C07 a stale guard on one record refuses the whole multi-record transaction (example E09/E11: neither account written).
- C09 a timed-out commit is
COMMIT_OUTCOME_UNKNOWNand a retry with the same id applies exactly once (example E11, a dropped answer: one transfer, not two). - C10 a duplicate request is a replay (example E05); a reused key with another
body is
IDEMPOTENCY_CONFLICT(example E06). - C12 bounded queries, ordering, cursors and limits.
- C14 a client in a separate process with no data path; example E17: the app
wrote nothing under its cwd or
HOME. - Example E12–E14: a refused credential reaches the app as a typed 401
AUTHENTICATION_REQUIRED, another application's id as a typed 403FORBIDDEN, an unreachable URL as 503SERVICE_UNAVAILABLE. E15: state and transaction-id replay survive an app restart.
Conditional — holds on the server built from this tree, not (yet) on 0.11.9.
- C02, C04, C08, C13 failed live with the suite's client of 2026-10-28 because
that client surfaced refusals and interrupted reads as untyped errors. The fix
is in the client (
@feltdb/corefrom this tree;[ERR-002],[ERR-003]) and the cases pass on the server target; the example's E12–E14 show the typed codes against live. Conditional on upgrading@feltdb/core; the contract cases themselves have not been re-run live. - C11 a duplicate reports the original commit position: passes on the server
target, fails on 0.11.9. Do not rely on
commitRevision/stateBefore/stateAfterof a replay on managed until it is redeployed. - The structured
failure: on 0.11.9,failure.collectionis a storage namespace (tenant_…:app_…:production:<collection>) andidis missing (example E09), and a create of an existing record is answeredCONFLICTwithout afailure(E07; the client still reportsPRECONDITION_FAILED). Branch oncode; readfailuredefensively until managed is redeployed. - C15 restart durability passes on the server target (committed state and
transaction-id replay survive
SIGKILL+ restart; example E16 likewise).
Unsupported or not verified.
- Restart recovery on managed is not verified (C15, E16
NOT RUN): there is no application-scoped restart operation, and restarting shared infrastructure was not authorized. Managed durability today rests on a single node with one volume and the provider's volume snapshots. - C16 (one platform identity per e-mail, verified passwords) was not run live.
- Power-loss durability is unproven on every runtime (guarantees).
- No developer-facing backup download or restore API on managed;
db.health()on the remote runtime; automatic retries of writes without atransactionId(never done, by design).