Skip to main content

Quickstart

This guide walks through defining an entity, ingesting events, and querying state. By the end you'll have a working entity engine running locally.

Install

pip install defacto

Defacto uses SQLite by default, so there's nothing else to set up.

Entity definition

Create a project directory with two subdirectories: entities/ and sources/.

The entity definition describes the state machine for a customer. Customers start as lead, transition to active on signup, can upgrade their plan, and move to churned on cancel.

entities/customer.yaml

customer:
starts: lead

identity:
email: { match: exact }

properties:
email: { type: string }
plan: { type: string, default: free }

states:
lead:
when:
signup:
effects:
- create
- { set: { property: email, from: event.email } }
- { transition: { to: active } }

active:
when:
upgrade:
guard: "event.plan != entity.plan"
effects:
- { set: { property: plan, from: event.plan } }
cancel:
effects:
- { transition: { to: churned } }

churned: {}

The identity section tells defacto how to recognize this entity. Customers are identified by email, so two events with the same email address are about the same customer.

The churned state has no handlers, making it terminal.

Source definition

The source definition tells defacto how to read raw events from a particular system: which field contains the event type, which contains the timestamp, and how fields map to entity properties.

sources/app.yaml

app:
event_type: type
timestamp: timestamp

events:
signup:
mappings:
email: { from: email }
hints:
customer: [email]

upgrade:
mappings:
email: { from: email }
plan: { from: plan }
hints:
customer: [email]

cancel:
mappings:
email: { from: email }
hints:
customer: [email]

The hints section connects events to entities. customer: [email] means "use the email field to identify which customer this event belongs to."

Ingesting events

Point defacto at your project directory and send it some events.

from defacto import Defacto

d = Defacto("my-project/")

d.ingest("app", [
{"type": "signup", "timestamp": "2024-01-01T10:00:00Z", "email": "alice@example.com"},
{"type": "signup", "timestamp": "2024-01-05T14:00:00Z", "email": "bob@example.com"},
{"type": "upgrade", "timestamp": "2024-01-15T09:00:00Z", "email": "alice@example.com", "plan": "pro"},
{"type": "cancel", "timestamp": "2024-03-01T12:00:00Z", "email": "bob@example.com"},
], process=True)

Setting process=True tells defacto to interpret events through the state machine as they arrive. Without it, events are appended to the ledger and processed when you call build().

Querying state

Current state

df = d.table("customer").execute()
customer_state email plan
active alice@example.com pro
churned bob@example.com free

Alice is active on the pro plan. Bob churned.

Point-in-time

df = d.history("customer").as_of("2024-01-10").execute()
customer_state email plan
active alice@example.com free
active bob@example.com free

On January 10th, both were active on the free plan. Alice hadn't upgraded yet, Bob hadn't cancelled.

Full history

df = d.history("customer").execute()
customer_state email plan valid_from valid_to
active alice@example.com free 2024-01-01T10:00:00+00:00 2024-01-15T09:00:00+00:00
active bob@example.com free 2024-01-05T14:00:00+00:00 2024-03-01T12:00:00+00:00
active alice@example.com pro 2024-01-15T09:00:00+00:00
churned bob@example.com free 2024-03-01T12:00:00+00:00

Each row represents a period of time when that state was true. An empty valid_to means the state is current.

Summary

ComponentWhat it does
Entity definitionDescribes the state machine (states, transitions, properties, identity)
Source definitionMaps raw event fields to entity properties and identity hints
ingest()Normalizes events and appends them to the ledger
process=TrueInterprets events through the state machine on ingest
table()Queries current entity state
history().as_of()Queries entity state at a specific point in time
history()Returns the full temporal state history

The default backend is SQLite. For production, pass a Postgres connection string and everything else stays the same.

For a more complete example with multiple entity types, multiple sources, automatic merges, and GDPR erasure, see examples/showcase/ in the repository.