Skip to main content
Version: 2.x

Configure storage backends

Prerequisites​

  • Run migrations and provision a tenant before constructing a store — see the CLI reference and multi-tenancy.
  • For the optional adapters in Python, install the postgres extra (and the s3 extra for the native S3 adapter); in TypeScript, import them from the Node-only @zonastery/bursar/node subpath.

Outcome​

  • A PostgresStore credit ledger that owns all account state.
  • Optional ClickHouse usage history and S3 billing archives delivered through a transactional outbox on the same connection pool.

PostgresStore is the canonical credit store. There is no bursar.stores package, and the former in-memory and Supabase HTTP stores have been removed. All account state lives in PostgreSQL.

Import PostgresStore and the CreditStore abstract base from the package top level:

from bursar import CreditStore, PostgresStore

PostgresStore​

The store is tenant-bound: pass the provisioned tenant UUID when constructing it. Python accepts tenant_id as a keyword argument. TypeScript accepts tenantId in the constructor options object.

store = PostgresStore(
database_url,
tenant_id=tenant_id,
provider_environment="test",
)

No store performs installation. Run bursar migrate with BURSAR_MIGRATION_DATABASE_URL and provision the tenant before constructing a store — see CLI reference and multi-tenancy. Bursar owns the schema, migrations, and tenant lifecycle; host migrations must not create, alter, or seed Bursar tables.

Custom stores subclass CreditStore. They must preserve idempotency, account locking semantics, append-only ledger history, cursor ordering by (created_at, entry_id), and atomic lot allocation, because the financial invariants in financial safety depend on them.

Optional analytics and archive adapters​

For high-volume applications, Bursar can route high-cardinality data into external systems through optional adapters. PostgreSQL remains the canonical source for balances, compact usage receipts, billing claims, and idempotency. In Python they live under bursar.storage (importing requires the postgres extra; the native S3 adapter also needs the s3 extra); in TypeScript they are exported from the Node-only @zonastery/bursar/node subpath.

AdapterPurpose
ClickHouseUsageStoreUsage history and analytics — skips PostgreSQL detail and rollup rows when enabled
S3BillingArchiveBilling payload archive — skips PostgreSQL raw envelope rows when enabled

High-cardinality usage details follow the usage backend: monthly PostgreSQL partitions with retention cleanup by default, or the ClickHouse usage projection when configured. Canonical billable usage receipts remain permanent; expired record-only receipts and PostgreSQL detail payloads are cleaned with the configured usage-retention horizon. S3 continues to own unbounded billing webhook envelopes, independently of the usage backend.

A transactional outbox carries the complete external payload in the same transaction as the canonical receipt or billing claim. An OutboxWorker delivers usage.charge_recorded to ClickHouse and billing.webhook_received to S3. This avoids a second permanent PostgreSQL copy while preserving retries during external outages. Exported rows and archive keys always carry tenant_id (S3 keys use <prefix>/tenants/<tenant-id>/billing-events/..., with the prefix defaulting to bursar).

External detail is eventually consistent: the PostgreSQL receipt or billing claim commits immediately, while ClickHouse history and S3 objects become visible after the outbox worker delivers them.

The worker renews each active claim, acknowledges only while it still owns that claim, and retries with bounded exponential backoff. Custom OutboxStore implementations must therefore implement claim renewal as well as claim, complete, and fail. Handlers must remain idempotent because an external write can succeed immediately before the acknowledgement is lost.

After Bursar's configured retention cleanup removes the PostgreSQL detail, the selected external adapter owns that detail: ClickHouse owns usage dimensions and metadata, while S3 owns raw billing envelopes. PostgreSQL continues to own balances, ledger entries, compact billable usage receipts, billing claims, and idempotency records. Configure an external adapter only when the host accepts this data-ownership contract. Bursar does not configure bucket policy, replication, backups, exporters, or service topology.

Adapter initialization and ownership​

ClickHouseUsageStore does not create tables by default. Either create the projection through the host's schema workflow and call checkSchemaCompatibility() / check_schema_compatibility(), or explicitly set createTable: true / create_table=True and call initializeSchema() / initialize_schema(). The adapter exposes writeUsageBatch() / write_usage_batch() for multi-row inserts; the single-row method delegates to the same projection path.

retentionDays / retention_days only changes DDL generated by initializeSchema() / initialize_schema(). It does not alter a table owned by the host when createTable / create_table is false; configure that table's TTL in the host's schema workflow instead.

S3BillingArchive uses the AWS SDK's normal credential and region provider chains when explicit values are omitted. A host can inject a configured client or lazy client factory and choose client ownership. Per-object encryption and checksum fields are available through putObject / put_object; bucket policy, versioning, lifecycle, and object-lock policy remain host-owned.

The runtime composition root wires the Postgres stores, the optional adapters, and the outbox worker onto separate tenant and operator connection pools:

from bursar.storage import BursarRuntimeOptions, BursarRuntimeStartOptions, create_bursar_runtime

runtime = create_bursar_runtime(
BursarRuntimeOptions(
postgres=database_url,
operator_postgres=operator_database_url,
tenant_id=tenant_id,
provider_environment="test",
clickhouse=clickhouse_options,
s3=s3_options,
outbox=outbox_options,
)
)
runtime.start(BursarRuntimeStartOptions(load_catalog=True))

The runtime exposes the composed bursar facade plus the underlying creditStore and billingStore. SDK runtimes claim outbox events through the tenant-filtered overload, so one runtime can never take another tenant's work. When ClickHouse is configured, analytics and usage-history methods route to ClickHouse; when S3 is configured, billing envelopes route to S3. Without either adapter, the runtime uses PostgreSQL for those methods and payloads. On startup, the built-in ClickHouse adapter performs a non-mutating schema compatibility check before the outbox worker starts, including after optional SDK-owned table creation.

Invoke maintenance and inspect dependencies​

Bursar never starts a maintenance scheduler. The host calls bounded passes at the cadence it chooses:

from bursar.storage import MaintenanceRunOptions, OperatorMaintenanceRunOptions

tenant_result = runtime.maintenance.run_once(MaintenanceRunOptions(limit=100))
storage_result = runtime.operator_maintenance.run_once(
OperatorMaintenanceRunOptions(mode="if_due")
)
diagnostics = runtime.check_dependencies()

state() is local and side-effect free. checkDependencies() / check_dependencies() actively checks PostgreSQL, the catalog, and outbox status, and returns safe diagnostic codes rather than raw exception messages. Both surfaces distinguish financialReady / financial_ready from projectionReady / projection_ready. A failed outbox check, stopped local worker, or any dead letter makes the optional projection degraded without hiding whether PostgreSQL-backed financial operations remain available. The maintenance results report counts and hasMore so the host can continue bounded work without Bursar owning a timer or process lifecycle.

Recover dead letters​

The runtime exposes its tenant-bound recovery port as outboxRecovery in TypeScript and outbox_recovery in Python. It supports aggregate stats, bounded keyset listDeadLetters() / list_dead_letters(), and explicit requeue() of one dead letter. The listing deliberately excludes payloads and claim tokens. Requeue only after correcting the sink or event problem; delivery remains at least once and handlers remain idempotent.