Skip to content
Sign in
← Documentation

Guarantees and limits

What FeltDB promises about persistence, atomic change, concurrency and recovery,

Guarantees and limits

What FeltDB promises about persistence, atomic change, concurrency and recovery, and — just as important — what it does not. Each claim names the test that holds it. The authoritative contract is the public API contract; the engineering detail is in local file persistence and crash durability.

Why this design

Most applications start by writing state to a table or a JSON file, then slowly grow their own answers to the same four questions: what if two writers race?, what if the process dies half-way?, what if a retry runs twice?, how do I know what is really committed? FeltDB answers those once, in the store, rather than in every application:

  • Every record has a version, and any write can be made conditional on it, so a read-modify-write cannot silently overwrite someone else's change.
  • A transaction commits several records, with conditions, as one unit.
  • A write is acknowledged only after it is durable, and what is durable is recovered exactly on restart.
  • A transaction can carry an identity, so an uncertain outcome can be retried without applying twice.

Those four are enough to build claims, leases, checkpoints and decision logs as ordinary records (agent state is exactly that), without each team inventing a persistence and recovery protocol.

What survives what

| Failure | Memory | Node file store | FeltDB server (feltdb-server) | | --- | --- | --- | --- | | db.close() then reopen | Lost at process exit | Everything acknowledged | Everything acknowledged | | Process killed (SIGKILL) mid-write | Lost | Every acknowledged commit; never a partial one | Every acknowledged commit; never a partial one | | OS crash or power loss | Lost | Not established | Not established | | Corrupt or unknown-format store | n/a | Open is refused, files untouched | Open is refused, files untouched |

Evidence: process kill — packages/core/test/first-time-user.acceptance.test.mjs (step 10), packages/core/test/file-journal-persistence.test.mjs, e2e/pr25-process-crash-recovery.test.mjs, e2e/server-crash-recovery.test.mjs. The server column is the feltdb-server binary as tested here. Recovery after a restart of the hosted service at api.feltdb.com is not verified; see managed production.

Acknowledged means the call returned. On the file store the commit record and its commit marker have each been flushed with fdatasync first. That survives your process dying. Whether it survives the machine losing power depends on the operating system and drive honouring the flush; FeltDB's own readiness matrix records power-loss durability as unproven (crash durability). Do not rely on it as if it were established.

Recovery on open. The file store replays its journal. A final append that never received its commit marker was never acknowledged and is discarded. A journal that is corrupt before its end, or in a newer format than this build understands, makes the open fail (JournalCorruptionError, UnsupportedDurableFormatError) and leaves the files as found, rather than guessing.

Ask the runtime rather than assuming: db.capabilities() reports what the store you opened supports (transactions, state contracts, content, durable Work), and db.deployment where it lives.

Atomicity and concurrency

  • A transaction is all or nothing. A whole transaction is one journal record, so a crash leaves it committed or absent, never partial. A refused transaction (failed condition) writes nothing.
  • Commits are applied one at a time, in one order. Conditions (expectedVersion, requireAbsent, leases, read-basis fences) are evaluated against the state immediately before that commit, inside the commit. Of two writers racing on the same condition, exactly one wins: a written record guarded by expectedVersion: N — on the write or as a transaction-level precondition — is written at N + 1, so a second transaction guarded on N is refused (packages/core/test/transaction-precondition-contract.test.mjs, e2e/transaction-precondition-contract.test.mjs: 50 rounds of 8 racing clients against a real server).
  • One process owns a file store by default; several may share it when asked. createFeltDB({ path }) opens the directory exclusively (since 0.14.0): a second process is refused with LocalStateLockedError (STATE_LOCKED) until the first calls db.close() or exits, and a lock left by a process that died is recovered on the next open (packages/core/test/local-state-lock.test.mjs). With createFeltDB({ path, access: 'shared' }) several processes open the same directory and a cross-process writer lock serializes their commits (e2e/pr25-multiprocess-concurrency.test.mjs).
  • Versions only increase. Replacing a record, restarting, or reopening never lowers __version, so a stale writer cannot be mistaken for a current one (packages/core/test/collection-write-contract.test.mjs).
  • Reads are not part of a write unless you say so. A plain get followed by a plain write is two separate steps. Use updateIfVersion (or expectedVersion in a transaction) to turn "I read version N" into a condition on the write. Use update(id, changes) only for changes that do not depend on what you read; it merges fields, retrying on conflict.
  • Not provided: multi-statement read snapshots across collections inside one transaction, and cross-store transactions. db.snapshot() gives a coherent read-only view for hot reads; read-basis fences (experimental) make a write conditional on a set of records staying unchanged.

Uncertain outcomes

A remote call can fail after the server committed. FeltDB distinguishes that from failure: it reports COMMIT_OUTCOME_UNKNOWN, meaning do not assume either way. The way out is a transaction identity:

await db.transaction(build, { transactionId: `ship-${orderId}` });   // safe to repeat

A transaction id names one payload. What a repeat tells you:

| You send | You get | Meaning | | --- | --- | --- | | a new id | the commit | applied | | the same id, the same request | duplicate: true, binding: 'verified' | this is the original; nothing re-applied; the result reports the original commit's stateBefore/stateAfter, not today's | | the same id, a different request | IDEMPOTENCY_CONFLICT | something else already used this id; nothing was applied; choose a new id | | an id committed before digests were recorded | duplicate: true, binding: 'legacy-unverified' | the id is known but the request could not be compared; reconcile from state if it might differ | | an id whose recorded identity this build cannot read | IDEMPOTENCY_UNVERIFIABLE | do not assume either way |

"Same request" means the same operations, preconditions and fences, in the same order: object key order and whitespace do not matter, array order does. A retry whose request is itself invalid is INVALID_REQUEST, never "duplicate". The same holds on the memory, file and IndexedDB runtimes (including across browser tabs) and on the managed server.

Where this holds, by backend and route:

| Backend / route | Enforced | Notes | | --- | --- | --- | | Memory, Node file, IndexedDB | yes | across browser tabs too | | Managed server (POST /transactions, POST /v1/transactions), WASM | yes | | | Process authority (AuthorityClient::commit) | yes | its own digest construction; a lost answer is reported COMMIT_OUTCOME_UNKNOWN | | Internal writers inside the server | not applicable | they never reuse an id |

Ids written before binding existed are legacy-unverified on every backend.

Limits to know:

  • A downgrade to a build that predates this (for example back to 0.12.1) reads the store and keeps every record, but its next compaction drops the recorded digests. Ids committed before the downgrade then become legacy-unverified. State is preserved; verification of historical ids is weakened. Downgrade is not lossless.
  • Ids are remembered for as long as the store remembers them; this page does not promise they are remembered forever, and a future retention policy will be decided separately.
  • An id guarantees the FeltDB commit is applied once. It does not make an external side effect (an email, a payment) happen once. Record the intent in FeltDB, perform the effect with its own idempotency key, then record the outcome — and treat an intent with no outcome after a crash as uncertain, to be reconciled, not assumed.

Errors, by meaning

| Outcome | Surface | | --- | --- | | Success | the call returns; for conditional writes { updated: true, item } or a committed transaction result | | Missing | get returns null; update/delete/updateIfVersion throw NOT_FOUND | | Invalid input | INVALID_REQUEST before anything is written | | Lost a race / failed precondition | updateIfVersion returns { updated: false, currentVersion }; a transaction throws ConditionalConflictError (PRECONDITION_FAILED, HTTP 409) with failure: { predicate, collection, id, key, expected, actual } — the same shape on every runtime and route — and commits none of its operations | | Id reused for a different transaction | IDEMPOTENCY_CONFLICT (nothing applied); IDEMPOTENCY_UNVERIFIABLE when the recorded identity cannot be judged | | Gave up retrying | VERSION_CONFLICT | | Storage failure | Not committed: STORAGE_FULL (HTTP 507), STORAGE_WRITE_FAILED or STORAGE_SYNC_FAILED (HTTP 503), with committed: false. Outcome unknown: DURABILITY_FAILURE (HTTP 503, committed: "unknown"), after which writes are RECOVERY_REQUIRED until the store is reopened. Never a lost race. See durability | | Read could not reach the authority | SERVICE_UNAVAILABLE (a read never leaves an uncertain commit behind) | | Unsupported on this runtime | CONTRACT_UNSUPPORTED, never an approximation | | Outcome uncertain | COMMIT_OUTCOME_UNKNOWN — retry only with the same transactionId |

Every failure above is a FeltDBServiceError with a stable code (and, from a server, status, requestId and the structured body). That holds for collections, db.query, db.transaction and state contracts. The older workflow, agent, capability and operation-export methods on db can still throw a plain Error.

Feature and guarantee matrix

| Capability | Memory | Node file | Browser | Managed | Status | | --- | --- | --- | --- | --- | --- | | Collections, get/insert/update/delete | ✓ | ✓ | ✓ | ✓ | Stable | | updateIfVersion, putIfAbsent, insert … requireAbsent | ✓ | ✓ | ✓ | ✓ | Stable | | db.transaction with conditions | ✓ | ✓ | ✓ | ✓ | Stable | | Bounded db.query, cursors, indexes | ✓ | ✓ | ✓ | ✓ (declared indexes) | Stable | | db.snapshot() | ✓ | ✓ | ✓ | ✓ | Stable | | Durable across restart | — | ✓ | ✓ | ✓ | Stable | | Transaction id bound to payload | ✓ | ✓ | ✓ | ✓ (feltdb-server; the hosted service's duplicate replay is listed in managed production) | Stable | | Power-loss durability | — | unproven | browser-defined | unproven | Unproven | | Read-basis fences | ✓ | ✓ | ✓ | ✓ | Experimental | | Agent state (tasks, checkpoints, handoffs) | — | ✓ | — | — | Experimental, file store only | | Authorization, history, principals | — | — | — | ✓ | Managed only |

The Browser column follows the API contract and is exercised by the IndexedDB conformance suite and e2e/browser-durability.test.mjs; the other columns are exercised directly by the tests named above.

What FeltDB does not do

  • It does not run your side effects, schedule your work, or decide what to do after a crash. It gives you the durable facts — what was claimed, what was checkpointed, what was recorded as done — to decide from.
  • It does not provide exactly-once execution. It provides at-most-once commit per transaction id and conditional writes that make duplicate work detectable.
  • It does not cache or mirror state elsewhere; the store is the authority.