Skip to main content

Storage Backends

bursar abstracts storage behind the CreditStore interface. All three built-in stores implement exactly the same interface, so you can swap between them — for example, use MemoryStore in tests and HttpxSupabaseStore in production — without changing any calling code.

Comparison

StoreDependenciesPersistentUse case
MemoryStoreNoneNo — lost on process exitUnit tests, local dev, CI
HttpxSupabaseStorehttpx (Python) / native fetch (JS)YesProduction (Supabase hosted)
PostgresStorepsycopg2 (Python) / pg (JS)YesProduction (self-hosted PostgreSQL)

MemoryStore is not persistent. All data is lost when the process exits. Do not use it in production.

CreditStore Interface

Every store implements CreditStore. All money arguments and return fields are exact decimals (decimal.Decimal in Python, Decimal from decimal.js in JavaScript; stored as NUMERIC(18,4) in Postgres). The JavaScript interface uses camelCase and adds listUsageEvents alongside listUserTransactions.

CategoryMethods
Balancesget_balance, add_credits, deduct_with_allowance, get_credit_tiers
Leasescreate_lease, settle_lease, release_lease, renew_lease, get_available
Pricingget_active_pricing, set_active_pricing, get_pricing_history, get_pricing_config, activate_pricing
Plansget_user_plan, check_feature, set_user_plan, check_allowance, increment_usage_window
Spend capscheck_spend_cap
Refundsrefund_credits
Expirysweep_expired_credits
Analytics (optional)spend_by_user, spend_by_model, top_users, daily_spend, aggregate_stats, list_user_transactions
Teams (optional)create_team, get_team_balance, add_team_member, get_team_members, deduct_team
Setupsetup

deduct_with_allowance is the canonical atomic "calculate-then-charge" entry point — allowance, spend cap, balance floor, and debit all commit (or roll back) together in one transaction.

Rows marked (optional) have default implementations that raise CapabilityNotSupportedError (exported from both SDKs) unless a custom store overrides them — everything else is core and required. See Architecture for the full breakdown.

MemoryStore

Zero dependencies. Thread-safe (Python uses a reentrant lock; JavaScript relies on single-threaded execution with no awaits between read and write).

from bursar.interface.memory import MemoryStore

store = MemoryStore()
# No setup() call needed
import { MemoryStore } from "@zonastery/bursar";

const store = new MemoryStore();
// No setup() call needed

Use it in tests by passing it directly to CreditManager. Because it is not persistent, each test process starts from a clean slate.

HttpxSupabaseStore

Backed by Supabase RPCs via HTTP. The Python implementation uses httpx; the JavaScript implementation uses the built-in fetch API. Neither depends on the supabase-py or supabase-js client libraries.

Requirements: Supabase project URL and service role key. The service role key has full database access — do not expose it to clients.

from bursar.interface.supabase import HttpxSupabaseStore

store = HttpxSupabaseStore(
url="https://<project>.supabase.co",
key="<service_role_key>",
)
# store is also a context manager: `with HttpxSupabaseStore(...) as store:`
import { HttpxSupabaseStore } from "@zonastery/bursar";

const store = new HttpxSupabaseStore(
"https://<project>.supabase.co",
"<service_role_key>",
);

First-time setup (run once): HttpxSupabaseStore.setup() cannot run migrations over the REST API. Apply the bundled SQL migrations via the Python CLI or directly against your database:

DATABASE_URL="postgresql://..." bursar migrate

Or from Python:

import os
from bursar.interface.supabase import run_migrations
result = run_migrations(os.environ["DATABASE_URL"])
print(result.tables_created, result.errors)

PostgresStore

Backed by a direct Postgres connection. The Python implementation uses psycopg2; the JavaScript implementation uses the pg package (lazy-loaded on first query, so the package is optional until the store is actually used).

Requirements: A Postgres connection string (any Postgres database that has the bursar schema installed).

from bursar.interface.postgres import PostgresStore

store = PostgresStore(database_url="postgresql://user:pass@host:5432/db")
import { PostgresStore } from "@zonastery/bursar";

const store = new PostgresStore("postgresql://user:pass@host:5432/db");
// Optional: inject a custom pool constructor for testing
// const store = new PostgresStore(url, MockPool);

// Close the pool when done
await store.close();

Schema migration: PostgresStore.setup() runs the bundled SQL migrations (Python). The JavaScript PostgresStore.setup() throws — run migrations via the Python CLI first, then point the JS store at the same database.

store = PostgresStore(database_url=os.environ["DATABASE_URL"])
result = store.setup()
print(result.tables_created, result.errors)

Or via CLI:

DATABASE_URL="postgresql://user:pass@host:5432/db" bursar migrate

The connection string is read from DATABASE_URL by the CLI; it is never passed on the command line.

SQL Migrations

The bundled SQL files create the required schema — 10 files, one per feature domain:

FileCreates
001_core_schema.sqluser_credits, credit_transactions, credit_reservations tables, RLS policies, signup bonus trigger
002_credit_rpcs.sqlCore RPCs: credits_add, get_credits_balance (user-scoped, atomic idempotency)
003_pricing_config.sqlcredit_pricing_config table, get/set/list/activate RPCs
004_plans.sqlcredit_plans, credit_usage_window tables, plan + allowance-window RPCs
005_spend_caps.sqlcredit_spend_caps table, check_spend_cap RPC
006_refunds_and_expiry.sqlrefund_credits (duplicate detection, partial refunds, tier LIFO restoration) and expire_credits (dry-run support; marks swept grants so it never double-sweeps)
007_analytics.sqlspend_by_user, spend_by_model, top_users, daily_spend, aggregate_stats, list_user_transactions, list_usage_events
008_teams.sqlcredit_teams, credit_team_members tables, team RPCs
009_deduct_and_leases.sqldeduct_with_allowance (atomic allowance + cap + floor + tier-aware debit) and the full lease lifecycle: create_lease, settle_lease, release_lease, renew_lease, get_available_credits, expire_due_leases
010_credit_tiers.sqlcredit_tiers config table, user_credit_tiers per-user-per-tier balances, sync_tiers_from_config, get_user_credit_tiers

All DDL is idempotent (IF NOT EXISTS / CREATE OR REPLACE, plus dynamic DROP FUNCTION-by-name guards for RPCs whose signature changed across releases). Migrations return a SetupResult with tables_created (list of applied files), rpcs_created, and errors. Check result.success (true when errors is empty) before going further.