Database Patterns
Common patterns used across the Twin backend database layer.
Soft Delete
Most entities support soft delete via a nullable deleted_at timestamp column. The record is never physically removed -- instead, deleted_at is set to the current time.
Model Configuration
Objection.js models use a notDeleted modifier to filter out soft-deleted rows by default:
Code
Deleting a Record
Use patch to set deleted_at instead of delete:
Code
Unique Constraints with Soft Delete
Regular UNIQUE constraints conflict with soft delete -- a soft-deleted row still occupies the index, preventing recreation of the same record.
Solution: Partial Unique Index
Add WHERE deleted_at IS NULL to only enforce uniqueness on active (non-deleted) rows:
Code
Tables Using This Pattern
| Table | Unique Columns | Notes |
|---|---|---|
circulation_wallet | (ops_token_id, wallet_address) | Per-token wallet tracking |
ops_token | (LOWER(address), network) | Case-insensitive address matching |
bridge_route | (bridge_token_key, network) | One route per token per chain |
UniqueViolationError Handling
Objection.js wraps PostgreSQL's unique violation (code 23505) as a UniqueViolationError from db-errors, but the PG code is not always directly exposed. Check all three possible locations:
Code
Applied in: CirculationService.createWallet, MintingService.createWallet, OpsTokenService.createToken, OpsTokenService.updateToken, BridgeService.createRoute.
JSON Columns
Several tables use JSON/JSONB columns for flexible configuration:
| Table | Column | Content |
|---|---|---|
uniswap_wallet | pool_config | { poolId, token0, token1, fee, tickSpacing } |
uniswap_wallet | bot_parameters | { rebalanceTriggerPercent, priceBandPercent, maxBudgetToken0, maxBudgetToken1 } |
uniswap_wallet | oracle_tokens | Token symbols for price reference |
These are defined as jsonb in migrations and typed as interfaces in Objection.js models.
Transactions
For operations that span multiple tables, use Objection.js transactions:
Code
If any operation within the transaction throws, all changes are rolled back.
BaseModel
All models extend a shared BaseModel that provides:
idas primary keycreatedAtandupdatedAttimestamps (auto-managed by$beforeInsert/$beforeUpdate)tableNamestatic property- Common modifiers
Code
knexSnakeCaseMappers
The Knex configuration uses knexSnakeCaseMappers() to automatically convert between snake_case (database) and camelCase (JavaScript):
Code
This means:
- Migrations use
snake_casecolumn names (wallet_address,ops_token_id) - Models and services use
camelCaseproperty names (walletAddress,opsTokenId) - The conversion is automatic and bidirectional