From 8ccfcf80752b745ef03260d15d7a889332b54b68 Mon Sep 17 00:00:00 2001 From: Matt Luongo Date: Sun, 11 Jan 2026 13:03:54 -0500 Subject: [PATCH 1/6] Add minor unit and decimal string utils to Money --- packages/cent/src/money/index.ts | 57 ++++++++++++++++ packages/cent/test/money.test.ts | 109 +++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/packages/cent/src/money/index.ts b/packages/cent/src/money/index.ts index 0a19016..173a742 100644 --- a/packages/cent/src/money/index.ts +++ b/packages/cent/src/money/index.ts @@ -1551,6 +1551,63 @@ export class Money { return MoneyJSONSchema.parse(result) } + /** + * Get the amount as a decimal string (e.g., "100.50"). + * Useful for database storage in DECIMAL/NUMERIC columns. + * + * @returns The amount as a decimal string without currency symbol + * + * @example + * const price = Money("$100.50"); + * price.toDecimalString(); // "100.50" + * + * const btc = Money("1.5 BTC"); + * btc.toDecimalString(); // "1.5" + */ + toDecimalString(): string { + if (isFixedPointNumber(this.amount)) { + return this.amount.toString() + } + return this.amount.toDecimalString(50n) + } + + /** + * Get the amount in minor units (cents, satoshis, wei, etc.) + * scaled to the currency's canonical decimal places. + * + * @returns The amount as a bigint in the smallest currency unit + * + * @example + * const price = Money("$100.50"); + * price.toMinorUnits(); // 10050n (cents) + * + * const btc = Money("1.5 BTC"); + * btc.toMinorUnits(); // 150000000n (satoshis) + */ + toMinorUnits(): bigint { + const currencyDecimals = BigInt(this.currency.decimals) + const fixedPoint = isFixedPointNumber(this.amount) + ? this.amount + : toFixedPointNumber(this.amount, currencyDecimals) + + const currentDecimals = fixedPoint.decimals + const currentAmount = fixedPoint.amount + + if (currentDecimals === currencyDecimals) { + return currentAmount + } + + if (currentDecimals > currencyDecimals) { + // Truncate extra precision + const scale = 10n ** (currentDecimals - currencyDecimals) + return currentAmount / scale + } + + // Scale up + const scale = 10n ** (currencyDecimals - currentDecimals) + return currentAmount * scale + } + /** * Compare this Money instance with another Money instance * diff --git a/packages/cent/test/money.test.ts b/packages/cent/test/money.test.ts index ad7975d..5fd35e7 100644 --- a/packages/cent/test/money.test.ts +++ b/packages/cent/test/money.test.ts @@ -2,11 +2,13 @@ import { findFractionalUnitInfo, getCurrencyDisplayPart, Money, + MoneyFactory, MoneyJSONSchema, normalizeLocale, pluralizeFractionalUnit, shouldUseIsoFormatting, } from "../src/money" +import { FixedPointNumber } from "../src/fixed-point" import { type AssetAmount, CEIL, @@ -2980,4 +2982,111 @@ describe("Money", () => { }) }) }) + + describe("toDecimalString", () => { + it("should return decimal string for USD amounts", () => { + const money = MoneyFactory("$100.50") + expect(money.toDecimalString()).toBe("100.50") + }) + + it("should return decimal string without currency symbol", () => { + const money = MoneyFactory("€1234.56") + expect(money.toDecimalString()).toBe("1234.56") + }) + + it("should handle zero amounts", () => { + const money = MoneyFactory("$0.00") + expect(money.toDecimalString()).toBe("0.00") + }) + + it("should handle whole numbers", () => { + const money = MoneyFactory("$100") + expect(money.toDecimalString()).toBe("100.00") + }) + + it("should handle very small decimals", () => { + const money = MoneyFactory("0.00000001 BTC") + expect(money.toDecimalString()).toBe("0.00000001") + }) + + it("should handle large numbers without precision loss", () => { + // 900719925474099.28 - larger than MAX_SAFE_INTEGER + const money = MoneyFactory("$900719925474099.28") + expect(money.toDecimalString()).toBe("900719925474099.28") + }) + + it("should handle negative amounts", () => { + const money = MoneyFactory("-$50.25") + expect(money.toDecimalString()).toBe("-50.25") + }) + }) + + describe("toMinorUnits", () => { + it("should return cents for USD amounts", () => { + const money = MoneyFactory("$100.50") + expect(money.toMinorUnits()).toBe(10050n) + }) + + it("should return cents for EUR amounts", () => { + const money = MoneyFactory("€50.25") + expect(money.toMinorUnits()).toBe(5025n) + }) + + it("should return satoshis for BTC amounts", () => { + const money = MoneyFactory("1.5 BTC") + expect(money.toMinorUnits()).toBe(150000000n) + }) + + it("should handle whole numbers", () => { + const money = MoneyFactory("$100") + expect(money.toMinorUnits()).toBe(10000n) + }) + + it("should handle zero amounts", () => { + const money = MoneyFactory("$0.00") + expect(money.toMinorUnits()).toBe(0n) + }) + + it("should handle amounts created from minor units", () => { + const money = MoneyFactory(10050n, "USD") + expect(money.toMinorUnits()).toBe(10050n) + }) + + it("should truncate extra precision to currency decimals", () => { + // USD has 2 decimals, so extra precision should be truncated + const money = new Money( + usdCurrency, + new FixedPointNumber(100505n, 3n), // 100.505 with 3 decimals + ) + // Should truncate to 10050 cents (100.50) + expect(money.toMinorUnits()).toBe(10050n) + }) + + it("should handle large numbers without precision loss", () => { + // Create money with a very large amount + const money = MoneyFactory("$900719925474099.28") + expect(money.toMinorUnits()).toBe(90071992547409928n) + }) + + it("should handle negative amounts", () => { + const money = MoneyFactory("-$50.25") + expect(money.toMinorUnits()).toBe(-5025n) + }) + + it("should handle sub-satoshi BTC amounts by truncating", () => { + // BTC has 8 decimals, 0.000000001 would be sub-satoshi + const btcCurrency: Currency = { + name: "Bitcoin", + code: "BTC", + decimals: 8n, + symbol: "₿", + } + const money = new Money( + btcCurrency, + new FixedPointNumber(15n, 10n), // 0.0000000015 BTC (sub-satoshi) + ) + // Should truncate to 0 satoshis + expect(money.toMinorUnits()).toBe(0n) + }) + }) }) From 5fbf167b758bcbcba34ecac09a14d6a584013e5b Mon Sep 17 00:00:00 2001 From: Matt Luongo Date: Sun, 11 Jan 2026 13:11:47 -0500 Subject: [PATCH 2/6] Stub out the @thesis-co/cent-supabase package --- packages/cent-supabase/jest.config.js | 15 +++ packages/cent-supabase/package.json | 50 ++++++++ packages/cent-supabase/src/client.ts | 63 ++++++++++ packages/cent-supabase/src/helpers.ts | 101 ++++++++++++++++ packages/cent-supabase/src/index.ts | 26 +++++ packages/cent-supabase/src/types.ts | 133 ++++++++++++++++++++++ packages/cent-supabase/test/setup.ts | 8 ++ packages/cent-supabase/test/types.test.ts | 118 +++++++++++++++++++ packages/cent-supabase/tsconfig.json | 11 ++ pnpm-lock.yaml | 109 ++++++++++++++++++ 10 files changed, 634 insertions(+) create mode 100644 packages/cent-supabase/jest.config.js create mode 100644 packages/cent-supabase/package.json create mode 100644 packages/cent-supabase/src/client.ts create mode 100644 packages/cent-supabase/src/helpers.ts create mode 100644 packages/cent-supabase/src/index.ts create mode 100644 packages/cent-supabase/src/types.ts create mode 100644 packages/cent-supabase/test/setup.ts create mode 100644 packages/cent-supabase/test/types.test.ts create mode 100644 packages/cent-supabase/tsconfig.json diff --git a/packages/cent-supabase/jest.config.js b/packages/cent-supabase/jest.config.js new file mode 100644 index 0000000..5a924bc --- /dev/null +++ b/packages/cent-supabase/jest.config.js @@ -0,0 +1,15 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/test/**/*.test.ts'], + setupFilesAfterEnv: ['/test/setup.ts'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: './tsconfig.json', + }, + ], + }, +} diff --git a/packages/cent-supabase/package.json b/packages/cent-supabase/package.json new file mode 100644 index 0000000..0d747f4 --- /dev/null +++ b/packages/cent-supabase/package.json @@ -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" + } +} diff --git a/packages/cent-supabase/src/client.ts b/packages/cent-supabase/src/client.ts new file mode 100644 index 0000000..607df8d --- /dev/null +++ b/packages/cent-supabase/src/client.ts @@ -0,0 +1,63 @@ +import { createClient, type SupabaseClient } from "@supabase/supabase-js" +import type { CentSupabaseOptions, NormalizedConfig } from "./types" +import { normalizeConfig } from "./types" + +/** + * Extended Supabase client type with Money support + */ +export type CentSupabaseClient = SupabaseClient + +/** + * 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( + supabaseUrl: string, + supabaseKey: string, + options: CentSupabaseOptions, + supabaseOptions?: Parameters[2], +): CentSupabaseClient { + // Create the underlying Supabase client + const client = createClient(supabaseUrl, supabaseKey, supabaseOptions) + + // Normalize the configuration + const config: NormalizedConfig = normalizeConfig(options) + + // TODO: Return proxied client + // For now, return the raw client - proxy implementation coming in Phase C + return client as CentSupabaseClient +} diff --git a/packages/cent-supabase/src/helpers.ts b/packages/cent-supabase/src/helpers.ts new file mode 100644 index 0000000..3c5f17c --- /dev/null +++ b/packages/cent-supabase/src/helpers.ts @@ -0,0 +1,101 @@ +import type { MoneyColumnConfig } from "./types" + +/** + * Build a SELECT string with ::text casts for money columns. + * Useful for manual query building. + * + * @param columns - Column names to select (string or array) + * @param moneyColumns - Names of columns that are money + * @returns SELECT string with appropriate casts + * + * @example + * ```typescript + * moneySelect(['id', 'name', 'price'], ['price']) + * // Returns: "id, name, price::text" + * + * moneySelect('*', ['price', 'cost']) + * // Returns: "*, price::text as __cent_price, cost::text as __cent_cost" + * ``` + */ +export function moneySelect( + columns: string | string[], + moneyColumns: string[], +): string { + // TODO: Implement in Phase C + const colString = Array.isArray(columns) ? columns.join(", ") : columns + return colString +} + +/** + * Transform response data by converting string amounts to Money instances. + * Useful for RPC results or when using the raw Supabase client. + * + * @param data - Response data from Supabase + * @param columns - Money column configurations + * @returns Transformed data with Money instances + * + * @example + * ```typescript + * const { data } = await supabase.rpc('calculate_total', { order_id: '...' }); + * const result = parseMoneyResult(data, { + * total: { currencyCode: 'USD' } + * }); + * ``` + */ +export function parseMoneyResult( + data: T, + columns: Record, +): T { + // TODO: Implement in Phase C + return data +} + +/** + * Serialize Money instances to strings for database mutations. + * Also auto-populates currency columns from Money instances. + * + * @param data - Data containing Money instances + * @param columns - Money column configurations + * @returns Serialized data ready for insert/update + * + * @example + * ```typescript + * const serialized = serializeMoney( + * { total: Money('€150.00'), name: 'Order 1' }, + * { total: { currencyColumn: 'currency' } } + * ); + * // Returns: { total: '150.00', currency: 'EUR', name: 'Order 1' } + * ``` + */ +export function serializeMoney( + data: T, + columns: Record, +): T { + // TODO: Implement in Phase C + return data +} + +/** + * Transform a realtime payload by converting money columns to Money instances. + * Useful when using raw channel subscriptions. + * + * @param payload - Realtime payload from Supabase + * @param config - Table configuration with money columns + * @returns Transformed payload with Money instances + * + * @example + * ```typescript + * channel.on('postgres_changes', { ... }, (payload) => { + * const transformed = transformRealtimePayload(payload, { + * money: { price: { currencyCode: 'USD' } } + * }); + * }); + * ``` + */ +export function transformRealtimePayload( + payload: T, + config: { money: Record }, +): T { + // TODO: Implement in Phase C + return payload +} diff --git a/packages/cent-supabase/src/index.ts b/packages/cent-supabase/src/index.ts new file mode 100644 index 0000000..e39e3c5 --- /dev/null +++ b/packages/cent-supabase/src/index.ts @@ -0,0 +1,26 @@ +/** + * @thesis-co/cent-supabase + * + * Precision-safe money handling for Supabase/PostgREST. + * Automatically handles DECIMAL/NUMERIC columns to prevent JavaScript + * floating-point precision loss. + */ + +// Types +export type { + CentSupabaseOptions, + CurrencySource, + MoneyColumnConfig, + TableConfig, +} from "./types" + +// Factory function +export { createCentSupabaseClient } from "./client" + +// Helper functions for manual use +export { + moneySelect, + parseMoneyResult, + serializeMoney, + transformRealtimePayload, +} from "./helpers" diff --git a/packages/cent-supabase/src/types.ts b/packages/cent-supabase/src/types.ts new file mode 100644 index 0000000..f1e5485 --- /dev/null +++ b/packages/cent-supabase/src/types.ts @@ -0,0 +1,133 @@ +/** + * Configuration for a money column with dynamic currency (from another column) + */ +export interface DynamicCurrencyConfig { + /** Column name containing the currency code */ + currencyColumn: string + /** + * Whether values are stored in minor units (cents, satoshis, wei). + * @default false + */ + minorUnits?: boolean +} + +/** + * Configuration for a money column with static currency + */ +export interface StaticCurrencyConfig { + /** Static currency code (e.g., 'USD', 'EUR', 'BTC') */ + currencyCode: string + /** + * Whether values are stored in minor units (cents, satoshis, wei). + * @default false + */ + minorUnits?: boolean +} + +/** + * Configuration for a money column - either dynamic or static currency + */ +export type MoneyColumnConfig = DynamicCurrencyConfig | StaticCurrencyConfig + +/** + * Currency source type for convenience + */ +export type CurrencySource = MoneyColumnConfig + +/** + * Configuration for a single table + */ +export interface TableConfig { + /** + * Money column configurations. + * Key is the column name, value is the currency configuration. + */ + money: Record +} + +/** + * Options for creating a Cent-enhanced Supabase client + */ +export interface CentSupabaseOptions { + /** + * Table configurations. + * Key is the table name, value is the table configuration. + */ + tables: Record +} + +/** + * Internal normalized configuration + */ +export interface NormalizedConfig { + tables: Record +} + +/** + * Normalized table configuration with computed properties + */ +export interface NormalizedTableConfig { + money: Record + /** List of money column names for quick lookup */ + moneyColumns: string[] +} + +/** + * Normalized money column configuration + */ +export interface NormalizedMoneyColumnConfig { + /** Currency column name (for dynamic currency) */ + currencyColumn?: string + /** Static currency code */ + currencyCode?: string + /** Whether stored in minor units */ + minorUnits: boolean +} + +/** + * Type guard to check if a currency source uses a column + */ +export function hasCurrencyColumn( + config: MoneyColumnConfig, +): config is DynamicCurrencyConfig { + return "currencyColumn" in config +} + +/** + * Type guard to check if a currency source uses a static code + */ +export function hasCurrencyCode( + config: MoneyColumnConfig, +): config is StaticCurrencyConfig { + return "currencyCode" in config +} + +/** + * Normalize user-provided options into internal config format + */ +export function normalizeConfig(options: CentSupabaseOptions): NormalizedConfig { + const tables: Record = {} + + for (const [tableName, tableConfig] of Object.entries(options.tables)) { + const money: Record = {} + const moneyColumns: string[] = [] + + for (const [columnName, columnConfig] of Object.entries(tableConfig.money)) { + moneyColumns.push(columnName) + + money[columnName] = { + currencyColumn: hasCurrencyColumn(columnConfig) + ? columnConfig.currencyColumn + : undefined, + currencyCode: hasCurrencyCode(columnConfig) + ? columnConfig.currencyCode + : undefined, + minorUnits: columnConfig.minorUnits ?? false, + } + } + + tables[tableName] = { money, moneyColumns } + } + + return { tables } +} diff --git a/packages/cent-supabase/test/setup.ts b/packages/cent-supabase/test/setup.ts new file mode 100644 index 0000000..c4e090a --- /dev/null +++ b/packages/cent-supabase/test/setup.ts @@ -0,0 +1,8 @@ +// Test setup for @thesis-co/cent-supabase +// Add any global test configuration here + +// Ensure BigInt serialization works in Jest +expect.addSnapshotSerializer({ + test: (val) => typeof val === "bigint", + print: (val) => `${val}n`, +}) diff --git a/packages/cent-supabase/test/types.test.ts b/packages/cent-supabase/test/types.test.ts new file mode 100644 index 0000000..3f8d69b --- /dev/null +++ b/packages/cent-supabase/test/types.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "@jest/globals" +import { + hasCurrencyCode, + hasCurrencyColumn, + normalizeConfig, + type CentSupabaseOptions, + type MoneyColumnConfig, +} from "../src/types" + +describe("types", () => { + describe("hasCurrencyColumn", () => { + it("returns true for dynamic currency config", () => { + const config: MoneyColumnConfig = { currencyColumn: "currency" } + expect(hasCurrencyColumn(config)).toBe(true) + }) + + it("returns false for static currency config", () => { + const config: MoneyColumnConfig = { currencyCode: "USD" } + expect(hasCurrencyColumn(config)).toBe(false) + }) + }) + + describe("hasCurrencyCode", () => { + it("returns true for static currency config", () => { + const config: MoneyColumnConfig = { currencyCode: "USD" } + expect(hasCurrencyCode(config)).toBe(true) + }) + + it("returns false for dynamic currency config", () => { + const config: MoneyColumnConfig = { currencyColumn: "currency" } + expect(hasCurrencyCode(config)).toBe(false) + }) + }) + + describe("normalizeConfig", () => { + it("normalizes a simple config", () => { + const options: CentSupabaseOptions = { + tables: { + orders: { + money: { + total: { currencyColumn: "currency" }, + }, + }, + }, + } + + const config = normalizeConfig(options) + + expect(config.tables.orders).toBeDefined() + expect(config.tables.orders.moneyColumns).toEqual(["total"]) + expect(config.tables.orders.money.total).toEqual({ + currencyColumn: "currency", + currencyCode: undefined, + minorUnits: false, + }) + }) + + it("handles static currency configs", () => { + const options: CentSupabaseOptions = { + tables: { + products: { + money: { + price: { currencyCode: "USD" }, + }, + }, + }, + } + + const config = normalizeConfig(options) + + expect(config.tables.products.money.price).toEqual({ + currencyColumn: undefined, + currencyCode: "USD", + minorUnits: false, + }) + }) + + it("handles minorUnits flag", () => { + const options: CentSupabaseOptions = { + tables: { + wallets: { + money: { + balance_sats: { currencyCode: "BTC", minorUnits: true }, + }, + }, + }, + } + + const config = normalizeConfig(options) + + expect(config.tables.wallets.money.balance_sats.minorUnits).toBe(true) + }) + + it("handles multiple tables and columns", () => { + const options: CentSupabaseOptions = { + tables: { + orders: { + money: { + total: { currencyColumn: "currency" }, + tax: { currencyColumn: "currency" }, + }, + }, + products: { + money: { + price: { currencyCode: "USD" }, + cost: { currencyCode: "USD" }, + }, + }, + }, + } + + const config = normalizeConfig(options) + + expect(config.tables.orders.moneyColumns).toEqual(["total", "tax"]) + expect(config.tables.products.moneyColumns).toEqual(["price", "cost"]) + }) + }) +}) diff --git a/packages/cent-supabase/tsconfig.json b/packages/cent-supabase/tsconfig.json new file mode 100644 index 0000000..4d3324e --- /dev/null +++ b/packages/cent-supabase/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "target": "ES2020", + "lib": ["ES2020"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ce11b8..137734b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,6 +79,30 @@ importers: specifier: ^29.1.2 version: 29.4.0(@babel/core@7.28.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.9))(typescript@5.5.4) + packages/cent-supabase: + devDependencies: + '@supabase/supabase-js': + specifier: ^2.49.1 + version: 2.90.1 + '@thesis-co/cent': + specifier: workspace:* + version: link:../cent + '@types/jest': + specifier: ^29.5.12 + version: 29.5.14 + '@types/node': + specifier: ^20.11.24 + version: 20.19.9 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.9) + ts-jest: + specifier: ^29.1.2 + version: 29.4.0(@babel/core@7.28.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.9))(typescript@5.5.4) + typescript: + specifier: ^5.5.4 + version: 5.5.4 + packages/cent-zod: devDependencies: '@thesis-co/cent': @@ -427,6 +451,30 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@supabase/auth-js@2.90.1': + resolution: {integrity: sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng==} + engines: {node: '>=20.0.0'} + + '@supabase/functions-js@2.90.1': + resolution: {integrity: sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw==} + engines: {node: '>=20.0.0'} + + '@supabase/postgrest-js@2.90.1': + resolution: {integrity: sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ==} + engines: {node: '>=20.0.0'} + + '@supabase/realtime-js@2.90.1': + resolution: {integrity: sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w==} + engines: {node: '>=20.0.0'} + + '@supabase/storage-js@2.90.1': + resolution: {integrity: sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg==} + engines: {node: '>=20.0.0'} + + '@supabase/supabase-js@2.90.1': + resolution: {integrity: sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==} + engines: {node: '>=20.0.0'} + '@testing-library/dom@9.3.4': resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} engines: {node: '>=14'} @@ -493,6 +541,9 @@ packages: '@types/node@20.19.9': resolution: {integrity: sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==} + '@types/phoenix@1.6.7': + resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -510,6 +561,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -990,6 +1044,10 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -1726,6 +1784,9 @@ packages: jest-util: optional: true + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + turbo-darwin-64@2.7.3: resolution: {integrity: sha512-aZHhvRiRHXbJw1EcEAq4aws1hsVVUZ9DPuSFaq9VVFAKCup7niIEwc22glxb7240yYEr1vLafdQ2U294Vcwz+w==} cpu: [x64] @@ -2321,6 +2382,44 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@supabase/auth-js@2.90.1': + dependencies: + tslib: 2.8.1 + + '@supabase/functions-js@2.90.1': + dependencies: + tslib: 2.8.1 + + '@supabase/postgrest-js@2.90.1': + dependencies: + tslib: 2.8.1 + + '@supabase/realtime-js@2.90.1': + dependencies: + '@types/phoenix': 1.6.7 + '@types/ws': 8.18.1 + tslib: 2.8.1 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@supabase/storage-js@2.90.1': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + + '@supabase/supabase-js@2.90.1': + dependencies: + '@supabase/auth-js': 2.90.1 + '@supabase/functions-js': 2.90.1 + '@supabase/postgrest-js': 2.90.1 + '@supabase/realtime-js': 2.90.1 + '@supabase/storage-js': 2.90.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@testing-library/dom@9.3.4': dependencies: '@babel/code-frame': 7.27.1 @@ -2413,6 +2512,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/phoenix@1.6.7': {} + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.27)': @@ -2428,6 +2529,10 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.19.9 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': @@ -2945,6 +3050,8 @@ snapshots: human-signals@2.1.0: {} + iceberg-js@0.8.1: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -3866,6 +3973,8 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.28.0) jest-util: 29.7.0 + tslib@2.8.1: {} + turbo-darwin-64@2.7.3: optional: true From 3ba795b27f183876e7b88fa954409155f3d55140 Mon Sep 17 00:00:00 2001 From: Matt Luongo Date: Sun, 11 Jan 2026 13:17:51 -0500 Subject: [PATCH 3/6] Add tests for SELECT transformations --- packages/cent-supabase/test/select.test.ts | 247 +++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 packages/cent-supabase/test/select.test.ts diff --git a/packages/cent-supabase/test/select.test.ts b/packages/cent-supabase/test/select.test.ts new file mode 100644 index 0000000..a2a1db4 --- /dev/null +++ b/packages/cent-supabase/test/select.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "@jest/globals" +import { + getOriginalColumnName, + getTempColumnName, + isTempColumn, + rewriteSelect, + TEMP_COLUMN_PREFIX, +} from "../src/transform/select" +import type { NormalizedTableConfig } from "../src/types" + +describe("SELECT rewriting", () => { + // Helper to create a simple table config + const createConfig = ( + moneyColumns: string[], + ): NormalizedTableConfig => ({ + moneyColumns, + money: Object.fromEntries( + moneyColumns.map((col) => [ + col, + { currencyCode: "USD", minorUnits: false }, + ]), + ), + }) + + describe("rewriteSelect", () => { + describe("SELECT * handling", () => { + it("appends temp columns for money columns with SELECT *", () => { + const config = createConfig(["price", "cost"]) + const result = rewriteSelect("*", config) + + expect(result.select).toBe( + "*, price::text as __cent_price, cost::text as __cent_cost", + ) + expect(result.tempColumns).toEqual(["__cent_price", "__cent_cost"]) + }) + + it("handles single money column with SELECT *", () => { + const config = createConfig(["amount"]) + const result = rewriteSelect("*", config) + + expect(result.select).toBe("*, amount::text as __cent_amount") + expect(result.tempColumns).toEqual(["__cent_amount"]) + }) + + it("returns unchanged * when no money columns", () => { + const config = createConfig([]) + const result = rewriteSelect("*", config) + + expect(result.select).toBe("*") + expect(result.tempColumns).toEqual([]) + }) + + it("handles * with whitespace", () => { + const config = createConfig(["price"]) + const result = rewriteSelect(" * ", config) + + expect(result.select).toBe("*, price::text as __cent_price") + }) + }) + + describe("explicit column handling", () => { + it("casts money columns in explicit select", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, name, price", config) + + expect(result.select).toBe("id, name, price::text") + expect(result.tempColumns).toEqual([]) + }) + + it("casts multiple money columns", () => { + const config = createConfig(["price", "cost"]) + const result = rewriteSelect("id, price, name, cost", config) + + expect(result.select).toBe("id, price::text, name, cost::text") + }) + + it("leaves non-money columns unchanged", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, name, created_at", config) + + expect(result.select).toBe("id, name, created_at") + }) + + it("handles column aliases", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, price as product_price", config) + + expect(result.select).toBe("id, price::text as product_price") + }) + + it("handles AS keyword (case insensitive)", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, price AS product_price", config) + + expect(result.select).toBe("id, price::text AS product_price") + }) + }) + + describe("aggregate functions", () => { + it("casts sum() of money columns", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("sum(price)", config) + + expect(result.select).toBe("sum(price)::text") + }) + + it("casts avg() of money columns", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("avg(price)", config) + + expect(result.select).toBe("avg(price)::text") + }) + + it("casts min() and max() of money columns", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("min(price), max(price)", config) + + expect(result.select).toBe("min(price)::text, max(price)::text") + }) + + it("handles aggregate with alias", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("sum(price) as total", config) + + expect(result.select).toBe("sum(price)::text as total") + }) + + it("does not cast count()", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("count(*), sum(price)", config) + + expect(result.select).toBe("count(*), sum(price)::text") + }) + }) + + describe("already-cast columns", () => { + it("does not double-cast columns with ::text", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, price::text", config) + + expect(result.select).toBe("id, price::text") + }) + + it("does not modify columns with other casts", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, price::numeric", config) + + expect(result.select).toBe("id, price::numeric") + }) + + it("does not modify already-cast aggregate", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("sum(price)::text", config) + + expect(result.select).toBe("sum(price)::text") + }) + }) + + describe("nested relations (limitations)", () => { + it("passes through nested relation selects unchanged", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, items(id, quantity)", config) + + // Nested relations are not rewritten (documented limitation) + expect(result.select).toBe("id, items(id, quantity)") + }) + + it("still casts top-level money columns with nested relations", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, price, items(id, quantity)", config) + + expect(result.select).toBe("id, price::text, items(id, quantity)") + }) + }) + + describe("edge cases", () => { + it("handles empty string", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("", config) + + expect(result.select).toBe("") + }) + + it("handles columns with underscores", () => { + const config = createConfig(["unit_price", "total_cost"]) + const result = rewriteSelect("id, unit_price, total_cost", config) + + expect(result.select).toBe("id, unit_price::text, total_cost::text") + }) + + it("handles mixed whitespace", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, price, name", config) + + expect(result.select).toBe("id, price::text, name") + }) + + it("handles columns that are substrings of others", () => { + const config = createConfig(["price"]) + const result = rewriteSelect("id, price, price_history", config) + + // Should only cast 'price', not 'price_history' + expect(result.select).toBe("id, price::text, price_history") + }) + }) + }) + + describe("temp column utilities", () => { + describe("getTempColumnName", () => { + it("adds prefix to column name", () => { + expect(getTempColumnName("price")).toBe("__cent_price") + expect(getTempColumnName("total_cost")).toBe("__cent_total_cost") + }) + }) + + describe("isTempColumn", () => { + it("returns true for temp columns", () => { + expect(isTempColumn("__cent_price")).toBe(true) + expect(isTempColumn("__cent_total")).toBe(true) + }) + + it("returns false for regular columns", () => { + expect(isTempColumn("price")).toBe(false) + expect(isTempColumn("_price")).toBe(false) + expect(isTempColumn("cent_price")).toBe(false) + }) + }) + + describe("getOriginalColumnName", () => { + it("removes prefix from temp column", () => { + expect(getOriginalColumnName("__cent_price")).toBe("price") + expect(getOriginalColumnName("__cent_total_cost")).toBe("total_cost") + }) + + it("returns unchanged for non-temp columns", () => { + expect(getOriginalColumnName("price")).toBe("price") + expect(getOriginalColumnName("total")).toBe("total") + }) + }) + + describe("TEMP_COLUMN_PREFIX", () => { + it("is __cent_", () => { + expect(TEMP_COLUMN_PREFIX).toBe("__cent_") + }) + }) + }) +}) From 54c0d3aa35ca4ddc540acf868ba935027d5649de Mon Sep 17 00:00:00 2001 From: Matt Luongo Date: Sun, 11 Jan 2026 13:23:21 -0500 Subject: [PATCH 4/6] Add response and mutation transformation tests --- packages/cent-supabase/test/mutation.test.ts | 345 +++++++++++++++++++ packages/cent-supabase/test/response.test.ts | 264 ++++++++++++++ 2 files changed, 609 insertions(+) create mode 100644 packages/cent-supabase/test/mutation.test.ts create mode 100644 packages/cent-supabase/test/response.test.ts diff --git a/packages/cent-supabase/test/mutation.test.ts b/packages/cent-supabase/test/mutation.test.ts new file mode 100644 index 0000000..24f97f0 --- /dev/null +++ b/packages/cent-supabase/test/mutation.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, it } from "@jest/globals" +import { Money, MoneyClass } from "@thesis-co/cent" +import { + serializeMoneyInData, + serializeMoneyValue, + serializeRow, +} from "../src/transform/mutation" +import type { NormalizedTableConfig } from "../src/types" + +describe("mutation serialization", () => { + // Helper to create a table config + const createConfig = ( + columns: Record< + string, + { currencyCode?: string; currencyColumn?: string; minorUnits?: boolean } + >, + ): NormalizedTableConfig => ({ + moneyColumns: Object.keys(columns), + money: Object.fromEntries( + Object.entries(columns).map(([col, config]) => [ + col, + { + currencyCode: config.currencyCode, + currencyColumn: config.currencyColumn, + minorUnits: config.minorUnits ?? false, + }, + ]), + ), + }) + + describe("serializeMoneyInData", () => { + describe("basic Money serialization", () => { + it("serializes Money instance to decimal string", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { + id: 1, + name: "Widget", + price: Money("$99.99"), + } + + const result = serializeMoneyInData(data, config) + + expect(result.price).toBe("99.99") + expect(result.id).toBe(1) + expect(result.name).toBe("Widget") + }) + + it("serializes multiple Money columns", () => { + const config = createConfig({ + price: { currencyCode: "USD" }, + cost: { currencyCode: "USD" }, + }) + const data = { + id: 1, + price: Money("$100.00"), + cost: Money("$50.00"), + } + + const result = serializeMoneyInData(data, config) + + expect(result.price).toBe("100.00") + expect(result.cost).toBe("50.00") + }) + + it("passes through non-Money values unchanged", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { + id: 1, + price: "99.99", // Already a string + name: "Widget", + } + + const result = serializeMoneyInData(data, config) + + expect(result.price).toBe("99.99") + expect(result.id).toBe(1) + expect(result.name).toBe("Widget") + }) + + it("passes through numeric values unchanged", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { + id: 1, + price: 99.99, // Number + } + + const result = serializeMoneyInData(data, config) + + expect(result.price).toBe(99.99) + }) + }) + + describe("currency column auto-population", () => { + it("auto-populates currency column from Money instance", () => { + const config = createConfig({ total: { currencyColumn: "currency" } }) + const data = { + id: 1, + total: Money("€150.00"), + } + + const result = serializeMoneyInData(data, config) + + expect(result.total).toBe("150.00") + expect(result.currency).toBe("EUR") + }) + + it("auto-populates multiple currency columns", () => { + const config = createConfig({ + subtotal: { currencyColumn: "subtotal_currency" }, + shipping: { currencyColumn: "shipping_currency" }, + }) + const data = { + id: 1, + subtotal: Money("€100.00"), + shipping: Money("$10.00"), + } + + const result = serializeMoneyInData(data, config) + + expect(result.subtotal).toBe("100.00") + expect(result.subtotal_currency).toBe("EUR") + expect(result.shipping).toBe("10.00") + expect(result.shipping_currency).toBe("USD") + }) + + it("does not override existing currency column value", () => { + const config = createConfig({ total: { currencyColumn: "currency" } }) + const data = { + id: 1, + total: Money("€150.00"), + currency: "GBP", // Explicitly set (should warn or override?) + } + + // Implementation note: Could warn or throw if currency doesn't match + // For now, the Money's currency should take precedence + const result = serializeMoneyInData(data, config) + + expect(result.currency).toBe("EUR") // Money takes precedence + }) + + it("handles shared currency column with matching currencies", () => { + const config = createConfig({ + total: { currencyColumn: "currency" }, + tax: { currencyColumn: "currency" }, + }) + const data = { + id: 1, + total: Money("€150.00"), + tax: Money("€15.00"), + } + + const result = serializeMoneyInData(data, config) + + expect(result.total).toBe("150.00") + expect(result.tax).toBe("15.00") + expect(result.currency).toBe("EUR") + }) + + it("throws error for conflicting currencies on shared currency column", () => { + const config = createConfig({ + total: { currencyColumn: "currency" }, + tax: { currencyColumn: "currency" }, + }) + const data = { + id: 1, + total: Money("€150.00"), + tax: Money("$15.00"), // Different currency! + } + + expect(() => serializeMoneyInData(data, config)).toThrow( + /conflicting currencies/i, + ) + }) + }) + + describe("minor units mode", () => { + it("serializes to minor units when configured", () => { + const config = createConfig({ + balance_sats: { currencyCode: "BTC", minorUnits: true }, + }) + const data = { + id: 1, + balance_sats: Money("1.5 BTC"), // 150000000 satoshis + } + + const result = serializeMoneyInData(data, config) + + expect(result.balance_sats).toBe("150000000") + }) + + it("serializes cents correctly", () => { + const config = createConfig({ + amount_cents: { currencyCode: "USD", minorUnits: true }, + }) + const data = { + id: 1, + amount_cents: Money("$100.50"), + } + + const result = serializeMoneyInData(data, config) + + expect(result.amount_cents).toBe("10050") + }) + + it("handles minor units with currency column", () => { + const config = createConfig({ + amount_minor: { currencyColumn: "currency", minorUnits: true }, + }) + const data = { + id: 1, + amount_minor: Money("¥1000"), // JPY has 0 decimals + } + + const result = serializeMoneyInData(data, config) + + expect(result.amount_minor).toBe("1000") + expect(result.currency).toBe("JPY") + }) + }) + + describe("array handling", () => { + it("serializes array of rows", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = [ + { id: 1, price: Money("$10.00") }, + { id: 2, price: Money("$20.00") }, + { id: 3, price: Money("$30.00") }, + ] + + const result = serializeMoneyInData(data, config) + + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(3) + expect(result[0].price).toBe("10.00") + expect(result[1].price).toBe("20.00") + expect(result[2].price).toBe("30.00") + }) + + it("handles empty array", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const result = serializeMoneyInData([], config) + + expect(result).toEqual([]) + }) + }) + + describe("null and undefined handling", () => { + it("preserves null values", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { id: 1, price: null } + + const result = serializeMoneyInData(data, config) + + expect(result.price).toBeNull() + }) + + it("preserves undefined values", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { id: 1, name: "Test" } // price not included + + const result = serializeMoneyInData(data, config) + + expect(result.price).toBeUndefined() + }) + }) + + describe("precision preservation", () => { + it("preserves large number precision", () => { + const config = createConfig({ amount: { currencyCode: "USD" } }) + const data = { + id: 1, + amount: Money("900719925474099.28 USD"), + } + + const result = serializeMoneyInData(data, config) + + expect(result.amount).toBe("900719925474099.28") + }) + + it("preserves crypto precision (18 decimals)", () => { + const config = createConfig({ balance: { currencyCode: "ETH" } }) + const data = { + id: 1, + balance: Money("123456789.123456789012345678 ETH"), + } + + const result = serializeMoneyInData(data, config) + + expect(result.balance).toBe("123456789.123456789012345678") + }) + }) + }) + + describe("serializeRow", () => { + it("serializes a single row", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const row = { id: 1, price: Money("$50.00") } + + const result = serializeRow(row, config) + + expect(result.price).toBe("50.00") + }) + + it("does not mutate original row", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const originalMoney = Money("$50.00") + const row = { id: 1, price: originalMoney } + + serializeRow(row, config) + + expect(row.price).toBe(originalMoney) + expect(MoneyClass.isMoney(row.price)).toBe(true) + }) + }) + + describe("serializeMoneyValue", () => { + it("serializes to decimal string by default", () => { + const money = Money("$100.50") + const result = serializeMoneyValue(money, false) + + expect(result).toBe("100.50") + }) + + it("serializes to minor units when requested", () => { + const money = Money("$100.50") + const result = serializeMoneyValue(money, true) + + expect(result).toBe("10050") + }) + + it("handles BTC satoshis", () => { + const money = Money("1.5 BTC") + const result = serializeMoneyValue(money, true) + + expect(result).toBe("150000000") + }) + + it("preserves precision in decimal string", () => { + const money = Money("123456789.123456789012345678 ETH") + const result = serializeMoneyValue(money, false) + + expect(result).toBe("123456789.123456789012345678") + }) + }) +}) diff --git a/packages/cent-supabase/test/response.test.ts b/packages/cent-supabase/test/response.test.ts new file mode 100644 index 0000000..cbd1eef --- /dev/null +++ b/packages/cent-supabase/test/response.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from "@jest/globals" +import { Money, MoneyClass } from "@thesis-co/cent" +import { transformResponseData, transformRow } from "../src/transform/response" +import type { NormalizedTableConfig } from "../src/types" + +describe("response transformation", () => { + // Helper to create a table config + const createConfig = ( + columns: Record< + string, + { currencyCode?: string; currencyColumn?: string; minorUnits?: boolean } + >, + ): NormalizedTableConfig => ({ + moneyColumns: Object.keys(columns), + money: Object.fromEntries( + Object.entries(columns).map(([col, config]) => [ + col, + { + currencyCode: config.currencyCode, + currencyColumn: config.currencyColumn, + minorUnits: config.minorUnits ?? false, + }, + ]), + ), + }) + + describe("transformResponseData", () => { + describe("with temp columns (SELECT * pattern)", () => { + it("transforms temp column values to Money and removes temp columns", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { + id: 1, + name: "Widget", + price: 99.99, // Original (precision lost) + __cent_price: "99.99", // Temp column (precision preserved) + } + + const result = transformResponseData(data, config, ["__cent_price"]) + + expect(MoneyClass.isMoney(result.price)).toBe(true) + expect((result.price as MoneyClass).toDecimalString()).toBe("99.99") + expect(result.__cent_price).toBeUndefined() + expect(result.id).toBe(1) + expect(result.name).toBe("Widget") + }) + + it("handles multiple temp columns", () => { + const config = createConfig({ + price: { currencyCode: "USD" }, + cost: { currencyCode: "USD" }, + }) + const data = { + id: 1, + price: 100, + cost: 50, + __cent_price: "100.00", + __cent_cost: "50.00", + } + + const result = transformResponseData(data, config, [ + "__cent_price", + "__cent_cost", + ]) + + expect(MoneyClass.isMoney(result.price)).toBe(true) + expect(MoneyClass.isMoney(result.cost)).toBe(true) + expect(result.__cent_price).toBeUndefined() + expect(result.__cent_cost).toBeUndefined() + }) + }) + + describe("with direct string values (explicit SELECT pattern)", () => { + it("transforms string values to Money", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { + id: 1, + name: "Widget", + price: "99.99", // Already a string from ::text cast + } + + const result = transformResponseData(data, config) + + expect(MoneyClass.isMoney(result.price)).toBe(true) + expect((result.price as MoneyClass).toDecimalString()).toBe("99.99") + }) + + it("leaves non-string money columns unchanged (no cast was applied)", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { + id: 1, + price: 99.99, // Number - wasn't cast, don't transform + } + + const result = transformResponseData(data, config) + + // Number stays as number (user didn't cast it) + expect(result.price).toBe(99.99) + expect(MoneyClass.isMoney(result.price)).toBe(false) + }) + }) + + describe("with dynamic currency (currencyColumn)", () => { + it("reads currency from specified column", () => { + const config = createConfig({ total: { currencyColumn: "currency" } }) + const data = { + id: 1, + total: "150.00", + currency: "EUR", + } + + const result = transformResponseData(data, config) + + expect(MoneyClass.isMoney(result.total)).toBe(true) + expect((result.total as MoneyClass).currency.code).toBe("EUR") + }) + + it("handles different currencies per row", () => { + const config = createConfig({ amount: { currencyColumn: "currency" } }) + const rows = [ + { id: 1, amount: "100.00", currency: "USD" }, + { id: 2, amount: "200.00", currency: "EUR" }, + { id: 3, amount: "1.5", currency: "BTC" }, + ] + + const result = transformResponseData(rows, config) + + expect((result[0].amount as MoneyClass).currency.code).toBe("USD") + expect((result[1].amount as MoneyClass).currency.code).toBe("EUR") + expect((result[2].amount as MoneyClass).currency.code).toBe("BTC") + }) + }) + + describe("with minor units", () => { + it("converts minor units to Money", () => { + const config = createConfig({ + balance_sats: { currencyCode: "BTC", minorUnits: true }, + }) + const data = { + id: 1, + balance_sats: "150000000", // 1.5 BTC in satoshis + } + + const result = transformResponseData(data, config) + + expect(MoneyClass.isMoney(result.balance_sats)).toBe(true) + expect((result.balance_sats as MoneyClass).toDecimalString()).toBe("1.50000000") + }) + + it("handles cents as minor units", () => { + const config = createConfig({ + amount_cents: { currencyCode: "USD", minorUnits: true }, + }) + const data = { + id: 1, + amount_cents: "10050", // $100.50 in cents + } + + const result = transformResponseData(data, config) + + expect(MoneyClass.isMoney(result.amount_cents)).toBe(true) + expect((result.amount_cents as MoneyClass).toDecimalString()).toBe("100.50") + }) + }) + + describe("with arrays of rows", () => { + it("transforms all rows in array", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = [ + { id: 1, price: "10.00" }, + { id: 2, price: "20.00" }, + { id: 3, price: "30.00" }, + ] + + const result = transformResponseData(data, config) + + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(3) + result.forEach((row) => { + expect(MoneyClass.isMoney(row.price)).toBe(true) + }) + }) + + it("handles empty array", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const result = transformResponseData([], config) + + expect(result).toEqual([]) + }) + }) + + describe("null and undefined handling", () => { + it("preserves null values", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { id: 1, price: null } + + const result = transformResponseData(data, config) + + expect(result.price).toBeNull() + }) + + it("preserves undefined values", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const data = { id: 1, name: "Test" } // price not included + + const result = transformResponseData(data, config) + + expect(result.price).toBeUndefined() + }) + }) + + describe("large number precision", () => { + it("preserves precision for large numbers", () => { + const config = createConfig({ amount: { currencyCode: "USD" } }) + const data = { + id: 1, + amount: "900719925474099.28", // Larger than MAX_SAFE_INTEGER + } + + const result = transformResponseData(data, config) + + expect(MoneyClass.isMoney(result.amount)).toBe(true) + expect((result.amount as MoneyClass).toDecimalString()).toBe( + "900719925474099.28", + ) + }) + + it("preserves precision for crypto amounts", () => { + const config = createConfig({ balance: { currencyCode: "ETH" } }) + const data = { + id: 1, + balance: "123456789.123456789012345678", // 18 decimals + } + + const result = transformResponseData(data, config) + + expect(MoneyClass.isMoney(result.balance)).toBe(true) + // Should preserve all 18 decimals + expect((result.balance as MoneyClass).toDecimalString()).toBe( + "123456789.123456789012345678", + ) + }) + }) + }) + + describe("transformRow", () => { + it("transforms a single row", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const row = { id: 1, price: "50.00" } + + const result = transformRow(row, config) + + expect(MoneyClass.isMoney(result.price)).toBe(true) + }) + + it("does not mutate original row", () => { + const config = createConfig({ price: { currencyCode: "USD" } }) + const row = { id: 1, price: "50.00" } + + transformRow(row, config) + + expect(row.price).toBe("50.00") + }) + }) +}) From bc4cd474c4e75b7fe2de3cdeaef791e9c0e3c0f4 Mon Sep 17 00:00:00 2001 From: Matt Luongo Date: Sun, 11 Jan 2026 14:24:48 -0500 Subject: [PATCH 5/6] Implement query, realtime, and response wrappers --- packages/cent-supabase/src/client.ts | 24 +- packages/cent-supabase/src/helpers.ts | 68 ++++- packages/cent-supabase/src/index.ts | 5 +- packages/cent-supabase/src/proxy/client.ts | 48 ++++ .../cent-supabase/src/proxy/query-builder.ts | 153 ++++++++++++ packages/cent-supabase/src/proxy/realtime.ts | 65 +++++ .../cent-supabase/src/transform/mutation.ts | 105 ++++++++ .../cent-supabase/src/transform/response.ts | 142 +++++++++++ .../cent-supabase/src/transform/select.ts | 235 ++++++++++++++++++ 9 files changed, 830 insertions(+), 15 deletions(-) create mode 100644 packages/cent-supabase/src/proxy/client.ts create mode 100644 packages/cent-supabase/src/proxy/query-builder.ts create mode 100644 packages/cent-supabase/src/proxy/realtime.ts create mode 100644 packages/cent-supabase/src/transform/mutation.ts create mode 100644 packages/cent-supabase/src/transform/response.ts create mode 100644 packages/cent-supabase/src/transform/select.ts diff --git a/packages/cent-supabase/src/client.ts b/packages/cent-supabase/src/client.ts index 607df8d..f1f5807 100644 --- a/packages/cent-supabase/src/client.ts +++ b/packages/cent-supabase/src/client.ts @@ -1,6 +1,7 @@ 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 @@ -57,7 +58,24 @@ export function createCentSupabaseClient( // Normalize the configuration const config: NormalizedConfig = normalizeConfig(options) - // TODO: Return proxied client - // For now, return the raw client - proxy implementation coming in Phase C - return client as CentSupabaseClient + // Return proxied client with Money handling + return createClientProxy(client, config) as CentSupabaseClient +} + +/** + * 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( + client: SupabaseClient, + options: CentSupabaseOptions, +): CentSupabaseClient { + const config: NormalizedConfig = normalizeConfig(options) + return createClientProxy(client, config) as CentSupabaseClient } diff --git a/packages/cent-supabase/src/helpers.ts b/packages/cent-supabase/src/helpers.ts index 3c5f17c..4c479cd 100644 --- a/packages/cent-supabase/src/helpers.ts +++ b/packages/cent-supabase/src/helpers.ts @@ -1,4 +1,35 @@ -import type { MoneyColumnConfig } from "./types" +import type { MoneyColumnConfig, NormalizedTableConfig } from "./types" +import { rewriteSelect } from "./transform/select" +import { transformResponseData } from "./transform/response" +import { serializeMoneyInData } from "./transform/mutation" + +/** + * Normalize a simple column config to NormalizedTableConfig + */ +function normalizeColumnConfig( + columns: Record, +): NormalizedTableConfig { + const moneyColumns = Object.keys(columns) + const money: NormalizedTableConfig["money"] = {} + + for (const [col, config] of Object.entries(columns)) { + if ("currencyColumn" in config) { + money[col] = { + currencyColumn: config.currencyColumn, + currencyCode: undefined, + minorUnits: config.minorUnits ?? false, + } + } else { + money[col] = { + currencyColumn: undefined, + currencyCode: config.currencyCode, + minorUnits: config.minorUnits ?? false, + } + } + } + + return { moneyColumns, money } +} /** * Build a SELECT string with ::text casts for money columns. @@ -21,9 +52,17 @@ export function moneySelect( columns: string | string[], moneyColumns: string[], ): string { - // TODO: Implement in Phase C const colString = Array.isArray(columns) ? columns.join(", ") : columns - return colString + const config: NormalizedTableConfig = { + moneyColumns, + money: Object.fromEntries( + moneyColumns.map((col) => [ + col, + { currencyCode: "USD", currencyColumn: undefined, minorUnits: false }, + ]), + ), + } + return rewriteSelect(colString, config).select } /** @@ -46,8 +85,8 @@ export function parseMoneyResult( data: T, columns: Record, ): T { - // TODO: Implement in Phase C - return data + const config = normalizeColumnConfig(columns) + return transformResponseData(data, config, []) } /** @@ -71,8 +110,8 @@ export function serializeMoney( data: T, columns: Record, ): T { - // TODO: Implement in Phase C - return data + const config = normalizeColumnConfig(columns) + return serializeMoneyInData(data, config) } /** @@ -92,10 +131,19 @@ export function serializeMoney( * }); * ``` */ -export function transformRealtimePayload( +export function transformRealtimePayload( payload: T, config: { money: Record }, ): T { - // TODO: Implement in Phase C - return payload + const tableConfig = normalizeColumnConfig(config.money) + const result = { ...payload } + + if (result.new) { + result.new = transformResponseData(result.new, tableConfig, []) + } + if (result.old) { + result.old = transformResponseData(result.old, tableConfig, []) + } + + return result } diff --git a/packages/cent-supabase/src/index.ts b/packages/cent-supabase/src/index.ts index e39e3c5..6a61f84 100644 --- a/packages/cent-supabase/src/index.ts +++ b/packages/cent-supabase/src/index.ts @@ -14,8 +14,9 @@ export type { TableConfig, } from "./types" -// Factory function -export { createCentSupabaseClient } from "./client" +// Factory functions +export { createCentSupabaseClient, wrapSupabaseClient } from "./client" +export type { CentSupabaseClient } from "./client" // Helper functions for manual use export { diff --git a/packages/cent-supabase/src/proxy/client.ts b/packages/cent-supabase/src/proxy/client.ts new file mode 100644 index 0000000..35c52cf --- /dev/null +++ b/packages/cent-supabase/src/proxy/client.ts @@ -0,0 +1,48 @@ +import type { SupabaseClient } from "@supabase/supabase-js" +import type { NormalizedConfig } from "../types" +import { createQueryBuilderProxy } from "./query-builder" +import { createRealtimeChannelProxy } from "./realtime" + +/** + * Create a proxied Supabase client that handles Money columns + */ +export function createClientProxy>( + client: T, + config: NormalizedConfig, +): T { + return new Proxy(client, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver) + + if (typeof value === "function") { + // Intercept from() + if (prop === "from") { + return (tableName: string) => { + const queryBuilder = target.from(tableName) + const tableConfig = config.tables[tableName] + + // If no config for this table, return the original query builder + if (!tableConfig) { + return queryBuilder + } + + return createQueryBuilderProxy(queryBuilder, tableConfig) + } + } + + // Intercept channel() for realtime + if (prop === "channel") { + return (name: string, opts?: any) => { + const channel = target.channel(name, opts) + return createRealtimeChannelProxy(channel, config) + } + } + + // Pass through other methods + return value.bind(target) + } + + return value + }, + }) +} diff --git a/packages/cent-supabase/src/proxy/query-builder.ts b/packages/cent-supabase/src/proxy/query-builder.ts new file mode 100644 index 0000000..e661f9b --- /dev/null +++ b/packages/cent-supabase/src/proxy/query-builder.ts @@ -0,0 +1,153 @@ +import type { NormalizedTableConfig } from "../types" +import { rewriteSelect, type RewriteSelectResult } from "../transform/select" +import { transformResponseData } from "../transform/response" +import { serializeMoneyInData } from "../transform/mutation" + +// Query builder type - using interface for the methods we need +interface QueryBuilder { + select(columns: string, options?: unknown): unknown + insert(data: unknown, options?: unknown): unknown + update(data: unknown, options?: unknown): unknown + upsert(data: unknown, options?: unknown): unknown +} + +/** + * Create a proxied query builder that handles Money columns + */ +export function createQueryBuilderProxy( + queryBuilder: T, + tableConfig: NormalizedTableConfig, +): T { + return new Proxy(queryBuilder as object, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver) + + if (typeof value === "function") { + // Intercept select() + if (prop === "select") { + return createSelectInterceptor(target as QueryBuilder, tableConfig) + } + + // Intercept mutation methods + if (prop === "insert" || prop === "upsert") { + return createInsertInterceptor(target as QueryBuilder, prop, tableConfig) + } + + if (prop === "update") { + return createUpdateInterceptor(target as QueryBuilder, tableConfig) + } + + // Pass through other methods, but wrap the result + return (...args: unknown[]) => { + const result = (value as Function).apply(target, args) + if (result && typeof result === "object" && "then" in result) { + return createFilterBuilderProxy(result, tableConfig, { select: "*", tempColumns: [] }) + } + return result + } + } + + return value + }, + }) as T +} + +/** + * Create a select() interceptor that rewrites the SELECT clause + */ +function createSelectInterceptor( + target: QueryBuilder, + tableConfig: NormalizedTableConfig, +) { + return (columns?: string, options?: { head?: boolean; count?: "exact" | "planned" | "estimated" }) => { + const columnsStr = columns ?? "*" + const rewriteResult = rewriteSelect(columnsStr, tableConfig) + + const result = target.select(rewriteResult.select, options) + return createFilterBuilderProxy(result, tableConfig, rewriteResult) + } +} + +/** + * Create an insert/upsert interceptor that serializes Money values + */ +function createInsertInterceptor( + target: QueryBuilder, + method: "insert" | "upsert", + tableConfig: NormalizedTableConfig, +) { + return (data: unknown, options?: unknown) => { + const serialized = serializeMoneyInData(data, tableConfig) + const result = target[method](serialized, options) + return createFilterBuilderProxy(result, tableConfig, { select: "*", tempColumns: [] }) + } +} + +/** + * Create an update interceptor that serializes Money values + */ +function createUpdateInterceptor( + target: QueryBuilder, + tableConfig: NormalizedTableConfig, +) { + return (data: unknown, options?: unknown) => { + const serialized = serializeMoneyInData(data, tableConfig) + const result = target.update(serialized, options) + return createFilterBuilderProxy(result, tableConfig, { select: "*", tempColumns: [] }) + } +} + +/** + * Create a proxied filter builder that transforms response data + */ +export function createFilterBuilderProxy( + filterBuilder: T, + tableConfig: NormalizedTableConfig, + selectResult: RewriteSelectResult, +): T { + return new Proxy(filterBuilder as object, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver) + + if (typeof value === "function") { + // Intercept select() on filter builder (for chained selects) + if (prop === "select") { + return (columns?: string, options?: unknown) => { + const columnsStr = columns ?? "*" + const newSelectResult = rewriteSelect(columnsStr, tableConfig) + const result = (target as QueryBuilder).select(newSelectResult.select, options) + return createFilterBuilderProxy(result, tableConfig, newSelectResult) + } + } + + // Intercept then() to transform response + if (prop === "then") { + return (onfulfilled?: (value: unknown) => unknown, onrejected?: (reason: unknown) => unknown) => { + return (value as Function).call(target, (response: { data?: unknown }) => { + if (response && response.data) { + response.data = transformResponseData( + response.data, + tableConfig, + selectResult.tempColumns, + ) + } + return onfulfilled ? onfulfilled(response) : response + }, onrejected) + } + } + + // Pass through other methods, wrapping the result + return (...args: unknown[]) => { + const result = (value as Function).apply(target, args) + // If result is thenable, wrap it + if (result && typeof result === "object" && "then" in result) { + return createFilterBuilderProxy(result, tableConfig, selectResult) + } + return result + } + } + + return value + }, + }) as T +} diff --git a/packages/cent-supabase/src/proxy/realtime.ts b/packages/cent-supabase/src/proxy/realtime.ts new file mode 100644 index 0000000..40ed2f9 --- /dev/null +++ b/packages/cent-supabase/src/proxy/realtime.ts @@ -0,0 +1,65 @@ +import type { RealtimeChannel } from "@supabase/supabase-js" +import type { NormalizedConfig } from "../types" +import { transformResponseData } from "../transform/response" + +/** + * Create a proxied realtime channel that transforms Money columns in payloads + */ +export function createRealtimeChannelProxy( + channel: T, + config: NormalizedConfig, +): T { + return new Proxy(channel, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver) + + if (typeof value === "function") { + // Intercept on() for postgres_changes + if (prop === "on") { + return ( + type: string, + filter: { event: string; schema?: string; table?: string; filter?: string }, + callback: (payload: any) => void, + ) => { + // Only intercept postgres_changes + if (type === "postgres_changes" && filter.table) { + const tableConfig = config.tables[filter.table] + + if (tableConfig) { + // Wrap the callback to transform Money columns + const wrappedCallback = (payload: any) => { + if (payload.new) { + payload.new = transformResponseData(payload.new, tableConfig, []) + } + if (payload.old) { + payload.old = transformResponseData(payload.old, tableConfig, []) + } + callback(payload) + } + + const result = target.on(type as any, filter as any, wrappedCallback) + return createRealtimeChannelProxy(result as T, config) + } + } + + // Pass through for non-configured tables or other event types + const result = target.on(type as any, filter as any, callback) + return createRealtimeChannelProxy(result as T, config) + } + } + + // Pass through other methods, wrapping the result if it returns the channel + return (...args: unknown[]) => { + const result = value.apply(target, args) + // If result is the channel (for chaining), wrap it + if (result === target || (result && typeof result === "object" && "on" in result)) { + return createRealtimeChannelProxy(result as T, config) + } + return result + } + } + + return value + }, + }) +} diff --git a/packages/cent-supabase/src/transform/mutation.ts b/packages/cent-supabase/src/transform/mutation.ts new file mode 100644 index 0000000..be1c492 --- /dev/null +++ b/packages/cent-supabase/src/transform/mutation.ts @@ -0,0 +1,105 @@ +import { + MoneyClass, + type MoneyClass as Money, +} from "@thesis-co/cent" +import type { NormalizedTableConfig, NormalizedMoneyColumnConfig } from "../types" + +/** + * Serialize Money instances in mutation data to strings for database insertion. + * + * For each money column: + * 1. If the value is a Money instance: + * - Convert to decimal string (or minor units string if minorUnits: true) + * - Auto-populate the currency column from Money's currency code (if currencyColumn config) + * 2. Pass through non-Money values unchanged + * + * @param data - Data to be inserted/updated (single row or array) + * @param tableConfig - Normalized table configuration + * @returns Serialized data with Money instances converted to strings + */ +export function serializeMoneyInData( + data: T, + tableConfig: NormalizedTableConfig, +): T { + // Handle null/undefined + if (data == null) { + return data + } + + // Handle arrays + if (Array.isArray(data)) { + return data.map((row) => + serializeRow(row as Record, tableConfig), + ) as T + } + + // Handle single object + if (typeof data === "object") { + return serializeRow( + data as Record, + tableConfig, + ) as T + } + + return data +} + +/** + * Serialize a single row of data, converting Money instances to strings + */ +export function serializeRow( + row: Record, + tableConfig: NormalizedTableConfig, +): Record { + const result = { ...row } + + // Track currency column values to detect conflicts + const currencyColumnValues: Record = {} + + // Process each money column + for (const columnName of tableConfig.moneyColumns) { + const config = tableConfig.money[columnName] + const value = result[columnName] + + if (MoneyClass.isMoney(value)) { + // Serialize Money to string + result[columnName] = serializeMoneyValue(value, config.minorUnits) + + // Auto-populate currency column if configured + if (config.currencyColumn) { + const currencyCode = value.currency.code + + // Check for conflicts with other Money values targeting same currency column + if (config.currencyColumn in currencyColumnValues) { + const existingCurrency = currencyColumnValues[config.currencyColumn] + if (existingCurrency !== currencyCode) { + throw new Error( + `Conflicting currencies for column '${config.currencyColumn}': ` + + `${existingCurrency} vs ${currencyCode}`, + ) + } + } + + // Set currency column value + currencyColumnValues[config.currencyColumn] = currencyCode + result[config.currencyColumn] = currencyCode + } + } + // Non-Money values pass through unchanged + } + + return result +} + +/** + * Serialize a Money value to a string representation for database storage + */ +export function serializeMoneyValue( + money: Money, + minorUnits: boolean, +): string { + if (minorUnits) { + return money.toMinorUnits().toString() + } + return money.toDecimalString() +} diff --git a/packages/cent-supabase/src/transform/response.ts b/packages/cent-supabase/src/transform/response.ts new file mode 100644 index 0000000..0dbdcbe --- /dev/null +++ b/packages/cent-supabase/src/transform/response.ts @@ -0,0 +1,142 @@ +import { + Money as MoneyFactory, + type MoneyClass as Money, +} from "@thesis-co/cent" +import type { NormalizedTableConfig, NormalizedMoneyColumnConfig } from "../types" +import { getOriginalColumnName, isTempColumn, TEMP_COLUMN_PREFIX } from "./select" + +/** + * Transform response data by converting string amounts to Money instances. + * + * Handles two patterns: + * 1. Temp columns from SELECT * (e.g., `__cent_price` → use value for `price`) + * 2. Direct string values from explicit SELECT (e.g., `price: "100.50"`) + * + * @param data - Response data from Supabase (single row or array) + * @param tableConfig - Normalized table configuration + * @param tempColumns - List of temporary column names to process and remove + * @returns Transformed data with Money instances + */ +export function transformResponseData( + data: T, + tableConfig: NormalizedTableConfig, + tempColumns: string[] = [], +): T { + // Handle null/undefined + if (data == null) { + return data + } + + // Handle arrays + if (Array.isArray(data)) { + return data.map((row) => + transformRow(row as Record, tableConfig, tempColumns), + ) as T + } + + // Handle single object + if (typeof data === "object") { + return transformRow( + data as Record, + tableConfig, + tempColumns, + ) as T + } + + return data +} + +/** + * Transform a single row of data + */ +export function transformRow( + row: Record, + tableConfig: NormalizedTableConfig, + tempColumns: string[] = [], +): Record { + const result = { ...row } + const tempColumnSet = new Set(tempColumns) + + // Process each money column + for (const columnName of tableConfig.moneyColumns) { + const config = tableConfig.money[columnName] + const tempColumnName = `${TEMP_COLUMN_PREFIX}${columnName}` + + // Check if we have a temp column (SELECT * pattern) + if (tempColumnSet.has(tempColumnName) && tempColumnName in result) { + const value = result[tempColumnName] + if (typeof value === "string") { + const currencyCode = getCurrencyCode(result, config) + if (currencyCode) { + result[columnName] = createMoneyFromValue( + value, + currencyCode, + config.minorUnits, + ) + } + } + // Remove temp column + delete result[tempColumnName] + } + // Check for direct string value (explicit SELECT pattern) + else if (columnName in result) { + const value = result[columnName] + if (typeof value === "string") { + const currencyCode = getCurrencyCode(result, config) + if (currencyCode) { + result[columnName] = createMoneyFromValue( + value, + currencyCode, + config.minorUnits, + ) + } + } + // Non-string values (numbers, null, undefined) pass through unchanged + } + } + + // Clean up any remaining temp columns not in our config + for (const key of Object.keys(result)) { + if (isTempColumn(key)) { + delete result[key] + } + } + + return result +} + +/** + * Get the currency code for a money column from either config or row data + */ +function getCurrencyCode( + row: Record, + config: NormalizedMoneyColumnConfig, +): string | undefined { + if (config.currencyCode) { + return config.currencyCode + } + if (config.currencyColumn) { + const currency = row[config.currencyColumn] + if (typeof currency === "string") { + return currency + } + } + return undefined +} + +/** + * Create a Money instance from a string value and column config + */ +export function createMoneyFromValue( + value: string, + currencyCode: string, + minorUnits: boolean, +): Money { + if (minorUnits) { + // Value is in minor units (cents, satoshis), convert to Money + const minorAmount = BigInt(value) + return MoneyFactory(minorAmount, currencyCode) + } + // Value is a decimal string + return MoneyFactory(`${value} ${currencyCode}`) +} diff --git a/packages/cent-supabase/src/transform/select.ts b/packages/cent-supabase/src/transform/select.ts new file mode 100644 index 0000000..9b6be0b --- /dev/null +++ b/packages/cent-supabase/src/transform/select.ts @@ -0,0 +1,235 @@ +import type { NormalizedTableConfig } from "../types" + +/** + * Result of rewriting a SELECT clause + */ +export interface RewriteSelectResult { + /** The rewritten SELECT string */ + select: string + /** Temporary column names that need cleanup in response (for SELECT * pattern) */ + tempColumns: string[] +} + +/** + * Rewrite a SELECT clause to cast money columns to text for precision safety. + * + * For `SELECT *`: + * - Keeps the `*` + * - Appends `::text` casts with temp aliases for money columns + * - Example: `*` → `*, price::text as __cent_price` + * + * For explicit column lists: + * - Casts money columns directly + * - Example: `id, price` → `id, price::text` + * + * @param columns - The original SELECT columns string + * @param tableConfig - Normalized table configuration + * @returns The rewritten SELECT string and list of temp columns + */ +export function rewriteSelect( + columns: string, + tableConfig: NormalizedTableConfig, +): RewriteSelectResult { + const trimmed = columns.trim() + + // Handle empty string + if (!trimmed) { + return { select: "", tempColumns: [] } + } + + // Handle no money columns + if (tableConfig.moneyColumns.length === 0) { + return { select: trimmed, tempColumns: [] } + } + + // Handle SELECT * pattern + if (trimmed === "*") { + const tempColumns: string[] = [] + const casts = tableConfig.moneyColumns.map((col) => { + const tempName = getTempColumnName(col) + tempColumns.push(tempName) + return `${col}::text as ${tempName}` + }) + return { + select: `*, ${casts.join(", ")}`, + tempColumns, + } + } + + // Handle explicit column list + const result = rewriteExplicitColumns(trimmed, tableConfig.moneyColumns) + return { select: result, tempColumns: [] } +} + +/** + * Aggregate functions that should be cast when containing money columns + */ +const AGGREGATE_FUNCTIONS = ["sum", "avg", "min", "max"] + +/** + * Rewrite explicit column list, casting money columns to text + */ +function rewriteExplicitColumns( + columns: string, + moneyColumns: string[], +): string { + // Split by comma, preserving nested parentheses + const parts = splitColumns(columns) + + const rewritten = parts.map((part) => { + const trimmedPart = part.trim() + + // Skip if already has a cast (::) + if (hasCast(trimmedPart)) { + return trimmedPart + } + + // Check for aggregate functions + const aggregateMatch = matchAggregate(trimmedPart) + if (aggregateMatch) { + const { func, inner, alias } = aggregateMatch + // Check if inner column is a money column + if (moneyColumns.includes(inner)) { + const cast = `${func}(${inner})::text` + return alias ? `${cast}${alias}` : cast + } + return trimmedPart + } + + // Skip nested relations (contains parentheses but not an aggregate) + if (trimmedPart.includes("(")) { + return trimmedPart + } + + // Check for alias + const aliasMatch = matchAlias(trimmedPart) + if (aliasMatch) { + const { column, alias } = aliasMatch + if (moneyColumns.includes(column)) { + return `${column}::text${alias}` + } + return trimmedPart + } + + // Simple column - check if it's a money column (exact match) + if (moneyColumns.includes(trimmedPart)) { + return `${trimmedPart}::text` + } + + return trimmedPart + }) + + return rewritten.join(", ") +} + +/** + * Split columns by comma, respecting parentheses + */ +function splitColumns(columns: string): string[] { + const parts: string[] = [] + let current = "" + let depth = 0 + + for (const char of columns) { + if (char === "(") { + depth++ + current += char + } else if (char === ")") { + depth-- + current += char + } else if (char === "," && depth === 0) { + parts.push(current) + current = "" + } else { + current += char + } + } + + if (current) { + parts.push(current) + } + + return parts +} + +/** + * Check if a column expression already has a cast + */ +function hasCast(expr: string): boolean { + // Look for :: not inside parentheses + let depth = 0 + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++ + else if (expr[i] === ")") depth-- + else if (depth === 0 && expr[i] === ":" && expr[i + 1] === ":") { + return true + } + } + return false +} + +/** + * Match aggregate function pattern: func(column) [as alias] + */ +function matchAggregate( + expr: string, +): { func: string; inner: string; alias: string } | null { + const pattern = new RegExp( + `^(${AGGREGATE_FUNCTIONS.join("|")})\\s*\\(\\s*([^)]+)\\s*\\)(\\s+(?:as\\s+)?\\w+)?$`, + "i", + ) + const match = expr.match(pattern) + if (match) { + return { + func: match[1].toLowerCase(), + inner: match[2].trim(), + alias: match[3] || "", + } + } + return null +} + +/** + * Match alias pattern: column [as] alias + */ +function matchAlias(expr: string): { column: string; alias: string } | null { + // Match "column as alias" or "column alias" (but not aggregates) + const pattern = /^(\w+)\s+(as\s+\w+|AS\s+\w+)$/ + const match = expr.match(pattern) + if (match) { + return { + column: match[1], + alias: ` ${match[2]}`, + } + } + return null +} + +/** + * Prefix for temporary columns created during SELECT * rewriting + */ +export const TEMP_COLUMN_PREFIX = "__cent_" + +/** + * Get the temporary column name for a money column + */ +export function getTempColumnName(columnName: string): string { + return `${TEMP_COLUMN_PREFIX}${columnName}` +} + +/** + * Check if a column name is a temporary cent column + */ +export function isTempColumn(columnName: string): boolean { + return columnName.startsWith(TEMP_COLUMN_PREFIX) +} + +/** + * Get the original column name from a temporary column name + */ +export function getOriginalColumnName(tempColumnName: string): string { + if (!isTempColumn(tempColumnName)) { + return tempColumnName + } + return tempColumnName.slice(TEMP_COLUMN_PREFIX.length) +} From b2b765991ab1b53f83055c60f881cdc383e07b3c Mon Sep 17 00:00:00 2001 From: Matt Luongo Date: Sun, 11 Jan 2026 14:27:32 -0500 Subject: [PATCH 6/6] Document @thesis-co/cent-supabase --- README.md | 4 + packages/cent-supabase/README.md | 168 +++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 packages/cent-supabase/README.md diff --git a/README.md b/README.md index b911c33..dd3dfeb 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/packages/cent-supabase/README.md b/packages/cent-supabase/README.md new file mode 100644 index 0000000..25cdb71 --- /dev/null +++ b/packages/cent-supabase/README.md @@ -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` |