|
| 1 | +# @thesis-co/cent-supabase |
| 2 | + |
| 3 | +Integration for `@thesis-co/cent` for easy storage and querying in |
| 4 | +Supabase. |
| 5 | + |
| 6 | +## The problem |
| 7 | + |
| 8 | +The Supabase client returns `DECIMAL` columns as JSON numbers, losing |
| 9 | +precision: |
| 10 | + |
| 11 | +```typescript |
| 12 | +// Database stores: 19.99 |
| 13 | +const { data } = await supabase.from('products').select('price').single() |
| 14 | +console.log(data.price) // 19.990000000000002 |
| 15 | +``` |
| 16 | + |
| 17 | +This package wraps the Supabase client to cast money columns to text on the wire, then converts them to `Money` objects in your app. |
| 18 | + |
| 19 | +## Installation |
| 20 | + |
| 21 | +```bash |
| 22 | +npm install @thesis-co/cent-supabase @thesis-co/cent @supabase/supabase-js |
| 23 | +``` |
| 24 | + |
| 25 | +## Quick start |
| 26 | + |
| 27 | +```typescript |
| 28 | +import { createCentSupabaseClient } from '@thesis-co/cent-supabase' |
| 29 | +import { Money } from '@thesis-co/cent' |
| 30 | + |
| 31 | +const supabase = createCentSupabaseClient( |
| 32 | + process.env.SUPABASE_URL!, |
| 33 | + process.env.SUPABASE_ANON_KEY!, |
| 34 | + { |
| 35 | + tables: { |
| 36 | + products: { |
| 37 | + money: { |
| 38 | + // statically defined currencies (every price is in USD) |
| 39 | + price: { currencyCode: 'USD' }, |
| 40 | + cost: { currencyCode: 'USD' } |
| 41 | + } |
| 42 | + }, |
| 43 | + orders: { |
| 44 | + money: { |
| 45 | + // for dynamic currencies (each row has a |
| 46 | + // total and total_currency) |
| 47 | + total: { currencyColumn: 'total_currency' }, |
| 48 | + tax: { currencyColumn: 'tax_currency' } |
| 49 | + } |
| 50 | + } |
| 51 | + } |
| 52 | + } |
| 53 | +) |
| 54 | + |
| 55 | +// SELECT — returns Money objects |
| 56 | +const { data } = await supabase.from('products').select('*') |
| 57 | +console.log(data[0].price.toString()) // "$29.99" |
| 58 | + |
| 59 | +// INSERT — accepts Money objects |
| 60 | +await supabase.from('orders').insert({ |
| 61 | + total: Money('€150.00'), |
| 62 | + tax: Money('€15.00') |
| 63 | + // 'currency' column auto-populated as 'EUR' |
| 64 | +}) |
| 65 | + |
| 66 | +// Aggregates work too |
| 67 | +const { data: stats } = await supabase.from('orders').select('sum(total)').single() |
| 68 | +console.log(stats.sum.toString()) // "$1,234.56" |
| 69 | +``` |
| 70 | + |
| 71 | +## Configuration |
| 72 | + |
| 73 | +### Static currency |
| 74 | + |
| 75 | +When all rows use the same currency: |
| 76 | + |
| 77 | +```typescript |
| 78 | +products: { |
| 79 | + money: { |
| 80 | + price: { currencyCode: 'USD' } |
| 81 | + } |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +### Dynamic currency |
| 86 | + |
| 87 | +When currency varies per row (stored in another column): |
| 88 | + |
| 89 | +```typescript |
| 90 | +orders: { |
| 91 | + money: { |
| 92 | + total: { currencyColumn: 'currency' } |
| 93 | + } |
| 94 | +} |
| 95 | +``` |
| 96 | + |
| 97 | +On insert, the currency column is auto-populated from the Money object. |
| 98 | + |
| 99 | +### Minor units |
| 100 | + |
| 101 | +When storing cents, satoshis, or wei as integers: |
| 102 | + |
| 103 | +```typescript |
| 104 | +transactions: { |
| 105 | + money: { |
| 106 | + amount_sats: { currencyCode: 'BTC', minorUnits: true } |
| 107 | + } |
| 108 | +} |
| 109 | +// Database: 150000000 → Money("1.5 BTC") |
| 110 | +``` |
| 111 | + |
| 112 | +## Realtime |
| 113 | + |
| 114 | +Subscriptions automatically transform payloads: |
| 115 | + |
| 116 | +```typescript |
| 117 | +supabase |
| 118 | + .channel('orders') |
| 119 | + .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'orders' }, (payload) => { |
| 120 | + console.log(payload.new.total.toString()) // Money object |
| 121 | + }) |
| 122 | + .subscribe() |
| 123 | +``` |
| 124 | + |
| 125 | +## Helper functions |
| 126 | + |
| 127 | +For RPC results or manual transformations: |
| 128 | + |
| 129 | +```typescript |
| 130 | +import { parseMoneyResult, serializeMoney, moneySelect } from '@thesis-co/cent-supabase' |
| 131 | + |
| 132 | +// Transform RPC results |
| 133 | +const { data } = await supabase.rpc('calculate_total', { order_id: '...' }) |
| 134 | +const result = parseMoneyResult(data, { total: { currencyCode: 'USD' } }) |
| 135 | + |
| 136 | +// Serialize Money for custom mutations |
| 137 | +const serialized = serializeMoney({ price: Money('$99.99') }, { price: { currencyCode: 'USD' } }) |
| 138 | +// { price: '99.99' } |
| 139 | + |
| 140 | +// Build select string with casts |
| 141 | +moneySelect('id, name, price', ['price']) // "id, name, price::text" |
| 142 | +``` |
| 143 | + |
| 144 | +## Limitations |
| 145 | + |
| 146 | +- **Nested relations**: Money columns in nested selects (e.g., `orders(items(price))`) aren't auto-transformed. Use `parseMoneyResult` on nested data. |
| 147 | +- **Computed expressions**: Use explicit `::text` cast: `.select('(price * qty)::text as subtotal')` |
| 148 | +- **RPC functions**: Transform results with `parseMoneyResult` |
| 149 | + |
| 150 | +## Database Schema |
| 151 | + |
| 152 | +Use `DECIMAL`/`NUMERIC`, not PostgreSQL's `MONEY` type: |
| 153 | + |
| 154 | +```sql |
| 155 | +CREATE TABLE orders ( |
| 156 | + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), |
| 157 | + total DECIMAL(19,4) NOT NULL, |
| 158 | + currency TEXT NOT NULL, |
| 159 | + created_at TIMESTAMPTZ DEFAULT now() |
| 160 | +) |
| 161 | +``` |
| 162 | + |
| 163 | +| Use Case | PostgreSQL Type | |
| 164 | +|----------|-----------------| |
| 165 | +| USD, EUR | `DECIMAL(19,4)` | |
| 166 | +| BTC (8 decimals) | `DECIMAL(28,8)` | |
| 167 | +| ETH (18 decimals) | `DECIMAL(38,18)` | |
| 168 | +| Minor units | `BIGINT` | |
0 commit comments