Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,10 @@ console.log(btcRange.toString({ preferredUnit: "sat" })) // "100,000 sats - 1,00

## Other features

### Supabase Integration

For Supabase/PostgREST applications, see [`@thesis-co/cent-supabase`](./packages/cent-supabase) which automatically handles `DECIMAL`/`NUMERIC` columns, preventing JavaScript precision loss.

### Zod Integration

For input validation and parsing, see [`@thesis-co/cent-zod`](./packages/cent-zod) which provides Zod schemas for all `cent` types.
Expand Down
168 changes: 168 additions & 0 deletions packages/cent-supabase/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# @thesis-co/cent-supabase

Integration for `@thesis-co/cent` for easy storage and querying in
Supabase.

## The problem

The Supabase client returns `DECIMAL` columns as JSON numbers, losing
precision:

```typescript
// Database stores: 19.99
const { data } = await supabase.from('products').select('price').single()
console.log(data.price) // 19.990000000000002
```

This package wraps the Supabase client to cast money columns to text on the wire, then converts them to `Money` objects in your app.

## Installation

```bash
npm install @thesis-co/cent-supabase @thesis-co/cent @supabase/supabase-js
```

## Quick start

```typescript
import { createCentSupabaseClient } from '@thesis-co/cent-supabase'
import { Money } from '@thesis-co/cent'

const supabase = createCentSupabaseClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!,
{
tables: {
products: {
money: {
// statically defined currencies (every price is in USD)
price: { currencyCode: 'USD' },
cost: { currencyCode: 'USD' }
}
},
orders: {
money: {
// for dynamic currencies (each row has a
// total and total_currency)
total: { currencyColumn: 'total_currency' },
tax: { currencyColumn: 'tax_currency' }
}
}
}
}
)

// SELECT — returns Money objects
const { data } = await supabase.from('products').select('*')
console.log(data[0].price.toString()) // "$29.99"

// INSERT — accepts Money objects
await supabase.from('orders').insert({
total: Money('€150.00'),
tax: Money('€15.00')
// 'currency' column auto-populated as 'EUR'
})

// Aggregates work too
const { data: stats } = await supabase.from('orders').select('sum(total)').single()
console.log(stats.sum.toString()) // "$1,234.56"
```

## Configuration

### Static currency

When all rows use the same currency:

```typescript
products: {
money: {
price: { currencyCode: 'USD' }
}
}
```

### Dynamic currency

When currency varies per row (stored in another column):

```typescript
orders: {
money: {
total: { currencyColumn: 'currency' }
}
}
```

On insert, the currency column is auto-populated from the Money object.

### Minor units

When storing cents, satoshis, or wei as integers:

```typescript
transactions: {
money: {
amount_sats: { currencyCode: 'BTC', minorUnits: true }
}
}
// Database: 150000000 → Money("1.5 BTC")
```

## Realtime

Subscriptions automatically transform payloads:

```typescript
supabase
.channel('orders')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'orders' }, (payload) => {
console.log(payload.new.total.toString()) // Money object
})
.subscribe()
```

## Helper functions

For RPC results or manual transformations:

```typescript
import { parseMoneyResult, serializeMoney, moneySelect } from '@thesis-co/cent-supabase'

// Transform RPC results
const { data } = await supabase.rpc('calculate_total', { order_id: '...' })
const result = parseMoneyResult(data, { total: { currencyCode: 'USD' } })

// Serialize Money for custom mutations
const serialized = serializeMoney({ price: Money('$99.99') }, { price: { currencyCode: 'USD' } })
// { price: '99.99' }

// Build select string with casts
moneySelect('id, name, price', ['price']) // "id, name, price::text"
```

## Limitations

- **Nested relations**: Money columns in nested selects (e.g., `orders(items(price))`) aren't auto-transformed. Use `parseMoneyResult` on nested data.
- **Computed expressions**: Use explicit `::text` cast: `.select('(price * qty)::text as subtotal')`
- **RPC functions**: Transform results with `parseMoneyResult`

## Database Schema

Use `DECIMAL`/`NUMERIC`, not PostgreSQL's `MONEY` type:

```sql
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
total DECIMAL(19,4) NOT NULL,
currency TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
)
```

| Use Case | PostgreSQL Type |
|----------|-----------------|
| USD, EUR | `DECIMAL(19,4)` |
| BTC (8 decimals) | `DECIMAL(28,8)` |
| ETH (18 decimals) | `DECIMAL(38,18)` |
| Minor units | `BIGINT` |
15 changes: 15 additions & 0 deletions packages/cent-supabase/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/test/**/*.test.ts'],
setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: './tsconfig.json',
},
],
},
}
50 changes: 50 additions & 0 deletions packages/cent-supabase/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"name": "@thesis-co/cent-supabase",
"version": "0.0.1",
"description": "Supabase integration for @thesis-co/cent - precision-safe money handling for Supabase/PostgREST",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "https://github.com/thesis/cent.git",
"directory": "packages/cent-supabase"
},
"keywords": [
"supabase",
"postgrest",
"money",
"currency",
"finance",
"precision",
"decimal",
"bigint"
],
"author": "Matt Luongo (@mhluongo)",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"scripts": {
"lint": "pnpx @biomejs/biome check",
"lint:fix": "pnpx @biomejs/biome check --write",
"build": "tsc",
"test": "jest",
"prepublishOnly": "pnpm run build && pnpm run test && pnpm run lint"
},
"devDependencies": {
"@thesis-co/cent": "workspace:*",
"@supabase/supabase-js": "^2.49.1",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.24",
"jest": "^29.7.0",
"ts-jest": "^29.1.2",
"typescript": "^5.5.4"
},
"peerDependencies": {
"@thesis-co/cent": ">=0.0.5",
"@supabase/supabase-js": ">=2.0.0"
}
}
81 changes: 81 additions & 0 deletions packages/cent-supabase/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { createClient, type SupabaseClient } from "@supabase/supabase-js"
import type { CentSupabaseOptions, NormalizedConfig } from "./types"
import { normalizeConfig } from "./types"
import { createClientProxy } from "./proxy/client"

/**
* Extended Supabase client type with Money support
*/
export type CentSupabaseClient<DB = any> = SupabaseClient<DB>

/**
* Create a Cent-enhanced Supabase client.
*
* This wraps the standard Supabase client to automatically:
* - Cast money columns to text in SELECT queries (preserving precision)
* - Transform response data into Money instances
* - Serialize Money instances in mutations
* - Handle realtime subscriptions
*
* @param supabaseUrl - Your Supabase project URL
* @param supabaseKey - Your Supabase anon/service key
* @param options - Cent configuration specifying money columns per table
* @param supabaseOptions - Options passed to underlying createClient
* @returns Enhanced Supabase client with Money support
*
* @example
* ```typescript
* const supabase = createCentSupabaseClient(
* process.env.SUPABASE_URL,
* process.env.SUPABASE_ANON_KEY,
* {
* tables: {
* orders: {
* money: {
* total: { currencyColumn: 'currency' },
* tax: { currencyColumn: 'currency' }
* }
* },
* products: {
* money: {
* price: { currencyCode: 'USD' }
* }
* }
* }
* }
* );
* ```
*/
export function createCentSupabaseClient<DB = any>(
supabaseUrl: string,
supabaseKey: string,
options: CentSupabaseOptions,
supabaseOptions?: Parameters<typeof createClient>[2],
): CentSupabaseClient<DB> {
// Create the underlying Supabase client
const client = createClient<DB>(supabaseUrl, supabaseKey, supabaseOptions)

// Normalize the configuration
const config: NormalizedConfig = normalizeConfig(options)

// Return proxied client with Money handling
return createClientProxy(client, config) as CentSupabaseClient<DB>
}

/**
* Wrap an existing Supabase client with Money column handling.
*
* Use this when you already have a Supabase client instance and want
* to add Money handling to it.
*
* @param client - An existing Supabase client
* @param options - Cent configuration specifying money columns per table
* @returns Enhanced Supabase client with Money support
*/
export function wrapSupabaseClient<DB = any>(
client: SupabaseClient<DB>,
options: CentSupabaseOptions,
): CentSupabaseClient<DB> {
const config: NormalizedConfig = normalizeConfig(options)
return createClientProxy(client, config) as CentSupabaseClient<DB>
}
Loading