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
postgresextra (and thes3extra for the native S3 adapter); in TypeScript, import them from the Node-only@zonastery/bursar/nodesubpath.
Outcome
- A
PostgresStorecredit 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:
- Python
- TypeScript
from bursar import CreditStore, PostgresStore
import { CreditStore, PostgresStore } from "@zonastery/bursar";
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.
- Python
- TypeScript
store = PostgresStore(
database_url,
tenant_id=tenant_id,
provider_environment="test",
)
const store = new PostgresStore({
postgres: databaseUrl,
tenantId,
providerEnvironment: "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.
| Adapter | Purpose |
|---|---|
ClickHouseUsageStore | Usage history and analytics — skips PostgreSQL detail and rollup rows when enabled |
S3BillingArchive | Billing 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:
- Python
- TypeScript
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))
import { createBursarRuntime } from "@zonastery/bursar/node";
const runtime = await createBursarRuntime({
postgres: databaseUrl,
operatorPostgres: operatorDatabaseUrl,
tenantId,
providerEnvironment: "test",
clickhouse: clickhouseOptions,
s3: s3Options,
outbox: outboxOptions,
});
await runtime.start({ loadCatalog: 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:
- Python
- TypeScript
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()
const tenantResult = await runtime.maintenance.runOnce({ limit: 100 });
const storageResult = await runtime.operatorMaintenance.runOnce({
mode: "ifDue",
});
const diagnostics = await runtime.checkDependencies();
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.