Skip to content
Sign in
← Documentation

Managed FeltDB in production

The path from "it works on my laptop" to a server-side application whose

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.com currently runs release 0.11.9 (commit 9f2354e, built 2026-09-28), not the release this page ships with. Its measured behaviour is recorded in artifacts/managed-contract/managed.json and artifacts/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 transactionId you choose makes the transaction safe to resend: the same id with the same operations returns the original commit with duplicate: true and applies nothing. Derive it from something the caller sends (an Idempotency-Key header) when the caller may retry.
  • Every collection and field you write must be declared in the promoted contract. Reads are bounded: db.query needs a limit (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_UNKNOWN and 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 403 FORBIDDEN, an unreachable URL as 503 SERVICE_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/core from 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 / stateAfter of a replay on managed until it is redeployed.
  • The structured failure: on 0.11.9, failure.collection is a storage namespace (tenant_…:app_…:production:<collection>) and id is missing (example E09), and a create of an existing record is answered CONFLICT without a failure (E07; the client still reports PRECONDITION_FAILED). Branch on code; read failure defensively 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 a transactionId (never done, by design).