Skip to main content

Data model

The durable schema (Postgres or MySQL). Migrations live in adapters/sqlstore/migrations/{postgres,mysql} and run with goose. Every table carries tenant_id (campaign-scoped tables also merchant_id); JSON columns hold opaque, config-driven blobs the engine reads but the schema doesn't constrain.

Key tables

TablePurposeNotable columns
tenants / merchantsTenancy hierarchysettings.wallet_scope
identitiesGlobal person (internal)phone, emaileach globally unique
playersTenant-scoped membershipUNIQUE(tenant_id, identity_id)
gamesConfig-driven game definitionreward_handler, seed_generator, validator, handler_config, rules, ui (opaque), milestones, wallet_scope
prizesStock + delivery policyremaining (atomic deduct target), fulfillment (redemption mode + channel)
sessionsSingle-use play session (durable audit; live copy in Redis)seed_data, expires_at
play_historyImmutable audit of every playpayload, chosen rewards, metadata, trace_id
rewardsOne awarded unit + lifecyclestatus: won → claimed → fulfilled → revoked; code
fulfillment_tasksTransactional outboxstatus: pending → processing → fulfilled / failed / dead
wallet_ledger + balancesPoints / lucky-item ledgerkeyed by (tenant_id, scope_key, currency)
integrationsOutbound adapter registrationsevents[], config, status

Two principles encoded here

  1. Atomic stock, not locks. Awarding a unit is UPDATE prizes SET remaining = remaining - 1 WHERE id = ? AND remaining > 0 and a check of RowsAffected == 1 — inside the same transaction as the turn-decrement, history insert, reward record, and (if needed) the fulfillment task. Works identically on Postgres and MySQL.
  2. Config over schema. handler_config, rules, ui, milestones, and integration config are JSON. Adding a game shape or a UI feature changes the JSON, not the tables.

See Concepts → Generic engine for what the config columns mean.