FACTSTR
FACTSTR is a Rust event store built around immutable facts, command-context consistency, committed-batch streams, and durable replay.
It is meant to be easy to start with: append the first fact, read the relevant facts, check context safely, and keep feature-owned query models current from committed batches.
Repository Shape
The repository is organized around explicit categories:
factstrdefines the shared Rust contractfactstr-memory,factstr-sqlite, andfactstr-postgresare store implementations that preserve that contractfactstr-conformanceholds reusable semantic checks for store implementationsfactstr-interopdefines a backend-independent interop DTO boundary above the Rust core for later language adaptersfactstr-nodeis the first language adapter and currently exposes a memory-backed TypeScript and Node package on top offactstr-interop
factstr-interop is not a store implementation. It does not reimplement append, query, conditional append, streams, or durable streams. It keeps the first cross-language boundary explicit without making a language-specific adapter define the contract shape by accident.
It is internal support for language adapters in this repository, not the user-facing package target for this step.
factstr-node is not a store implementation. It is not a transport adapter. The current scope is intentionally narrow: a memory-backed TypeScript and Node package for append, query, and conditional append through the interop DTO boundary. The public package is intended to resolve prebuilt native binaries by platform, while factstr-interop stays internal support.
Release Model
Rust releases are managed by release-plz.
FACTSTR uses one unified public version line for its public artifacts. The current public version is 0.2.1.
- publishable crates:
factstr,factstr-memory,factstr-sqlite,factstr-postgres - non-products on crates.io:
factstr-interop, the Rustfactstr-nodecrate, andfactstr-conformance
The intended steady-state Rust path is GitHub Actions trusted publishing. Brand-new crates may still need a one-time bootstrap CARGO_REGISTRY_TOKEN in GitHub Actions for the first publish before trusted publishing takes over.
The npm release lane publishes only @factstr/factstr-node.
@factstr/factstr-nodeis the public npm package@factstr/factstr-nodefollows the same public release version as the Rust core crates- the prebuilt packages under
factstr-node/npm/*exist only for native distribution support
Release automation stays split by lane:
- Rust crates publish through the Rust release lane
@factstr/factstr-nodepublishes through the npm release lane
The public version line is shared even though the publish lanes remain separate. Node publishing is intended to run through GitHub Actions trusted publishing on GitHub-hosted runners, not from a developer laptop.
Links
- Website: https://factstr.com
- Local docs entry page: docs/index.md
- Getting started: docs/getting-started.md
- Streams: docs/streams.md
- Stores: docs/stores.md
- SQLite store guidance: docs/sqlite.md
- Reference: docs/reference.md
Why FACTSTR
FACTSTR is for software that should grow feature by feature without forcing aggregate-centric structure first.
It keeps the important behavior explicit:
- facts are append-only
- consistency is checked against the relevant command context
- reads stay ordered
- committed batches are delivered only after append succeeds
- durable streams replay from a stored cursor and then continue live
This gives a direct path for:
- starting with the first real feature
- keeping decisions local to the relevant facts
- updating feature-owned query models from committed batches
- replaying and catching up after restart
- choosing embedded or database-backed persistence without changing the core model
Implemented Now
The current shared contract supports:
- append
- query
- conditional append with typed conflict failure
stream_allstream_tostream_all_durablestream_to_durable- event-type filtering
- payload-predicate filtering
- explicit separation of:
last_returned_sequence_numbercurrent_context_version
The current public contract types are:
NewEventEventRecordEventFilterEventQueryQueryResultAppendResultHandleStreamStreamHandlerErrorDurableStreamEventStreamEventStoreEventStoreError
The current interop boundary types are:
InteropNewEventInteropEventRecordInteropEventFilterInteropEventQueryInteropQueryResultInteropAppendResultInteropConditionalAppendConflictInteropError
Stores
FACTSTR currently includes three store implementations.
factstr-memory
The memory store is the semantic reference implementation.
Use it for:
- tests
- local development
- direct reasoning about behavior
It implements:
- append
- query
- append_if
stream_allstream_tostream_all_durablestream_to_durable
Durable streams in memory are limited to the lifetime of one MemoryStore instance.
factstr-sqlite
The SQLite store is the embedded persistent implementation. For guidance on when SQLite is the right store and when it is not, see docs/sqlite.md.
Use it for:
- local persistence without external infrastructure
- durable replay and catch-up across restart
- validating the shared contract against an embedded store
It implements:
- append
- query
- append_if
stream_allstream_tostream_all_durablestream_to_durable
factstr-postgres
The PostgreSQL store implements the same contract on top of PostgreSQL using SQLx.
Use it for:
- teams that already operate PostgreSQL
- persistence with familiar database tooling
- validating the shared contract against a database-backed store
It implements:
- append
- query
- append_if
stream_allstream_tostream_all_durablestream_to_durable
Core Semantics
These are the load-bearing behaviors implemented today.
Append
- events are append-only
- sequence numbers are global and monotonically increasing
- one committed batch receives one consecutive sequence range
AppendResultreports:first_sequence_numberlast_sequence_numbercommitted_count
- empty append input returns
EventStoreError::EmptyAppend
Query
- returned events are ordered by ascending
sequence_number - each returned
EventRecordincludesoccurred_at occurred_atis recorded event time, not the ordering keymin_sequence_numberis an exclusive read cursorlast_returned_sequence_numberdescribes only the returned rowscurrent_context_versiondescribes the full matching contextcurrent_context_versionignoresmin_sequence_number
Conditional append
append_ifchecks the full conflict contextappend_ifignoresmin_sequence_numberfor conflict detection- stale context returns
EventStoreError::ConditionalAppendConflict - failed conditional append does not partially append a batch
- failed conditional append does not consume sequence numbers that later successful appends would observe
Streams
- notifications happen only after a successful commit
- each committed append batch is delivered as one batch
- mixed committed batches are delivered as one filtered batch when matches exist
- delivery order follows committed global sequence order
- failed conditional append delivers nothing
- multiple stream handlers can observe the same committed batches
- handler failure does not roll back append success
Durable streams
- durable replay starts strictly after the stored cursor
- replay/live transition has no duplicates or gaps
- durable cursors do not advance past undelivered committed facts
- Memory implements durable streams within one
MemoryStoreinstance only - SQLite implements durable streams with persisted cursors and replay across restart
- PostgreSQL implements durable streams with persisted cursors and replay across restart
Query Model
EventQuery currently contains:
filters: Option<Vec<EventFilter>>min_sequence_number: Option<u64>
EventFilter currently contains:
event_types: Option<Vec<String>>payload_predicates: Option<Vec<serde_json::Value>>
Current matching rules:
EventQuery.filtersis OR across filters- within one filter,
event_typesis OR across event types - within one filter,
payload_predicatesis OR across payload predicates - within one filter, event-type matching and payload matching are combined with AND
- omitted or empty
filtersmeans all events event_types: Nonemeans event type is unconstrainedpayload_predicates: Nonemeans payload is unconstrainedevent_types: Some([])means explicit empty event-type match setpayload_predicates: Some([])means explicit empty payload-predicate match set
Payload predicates use recursive subset matching.
Current rules:
- scalar values must be equal
- objects match recursively by subset
- arrays match if every predicate element is contained somewhere in the payload array
- array element containment uses the same recursive subset logic
- extra keys in the event payload are allowed
- extra array elements in the event payload are allowed
Quick Start
Check the workspace:
Run the memory-store tests:
Run the basic in-memory example:
Run the feature-owned query-model example:
Run the PostgreSQL store tests:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
Run the full workspace test suite with PostgreSQL enabled:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
Using FACTSTR From Another Repository
The current intended consumption path is a git dependency.