UNPKG

supabase-typed-query

Version:

Type-safe query builder and entity pattern for Supabase with TypeScript

363 lines (270 loc) 10.1 kB
# supabase-typed-query Type-safe query builder and entity pattern for Supabase with TypeScript. ## Features - 🔒 **Full TypeScript type safety** - Leverage your database types for compile-time safety - 🔗 **Chainable query API** - Build complex queries with OR conditions and functional operations - 🎯 **Entity pattern** - Consistent CRUD operations across all tables - 🚀 **Functional programming** - Built with functype for robust error handling - ⚡ **Zero runtime overhead** - All type checking happens at compile time - 🔄 **Composable queries** - Mix and match conditions, filters, and transformations - 🗑️ **Soft delete support** - Built-in soft delete filtering with per-query overrides - 🏢 **Multi-tenancy ready** - Automatic partition key filtering for tenant isolation ## Installation ```bash npm install supabase-typed-query functype # or pnpm add supabase-typed-query functype # or yarn add supabase-typed-query functype ``` ## Quick Start ### 1. Set up your database types First, generate your database types from Supabase: ```bash npx supabase gen types typescript --project-id your-project-id > database.types.ts ``` ### 2. Create a typed client ```typescript import { createClient } from "@supabase/supabase-js" import type { Database } from "./database.types" const supabase = createClient<Database>("your-supabase-url", "your-anon-key") ``` ### 3. Use the Query Builder ```typescript import { query } from "supabase-typed-query" // Simple query const user = await query(supabase, "users", { id: "123" }).one() // Query with OR conditions const posts = await query(supabase, "posts", { status: "published" }).or({ status: "draft", author_id: userId }).many() // Query with functional operations const titles = await query(supabase, "posts", { status: "published" }) .map((post) => post.title) .filter((title) => title.length > 10) .many() ``` ### 4. Use the Entity Pattern ```typescript import { Entity } from "supabase-typed-query" // Create an entity for your table with configuration const PostEntity = Entity(supabase, "posts", { softDelete: true, // Automatically exclude soft-deleted items }) // Get all posts const posts = await PostEntity.getItems({ where: { status: "published" }, order: ["created_at", { ascending: false }], }).many() // Get a single post const post = await PostEntity.getItem({ id: "post-123", where: { status: "published" }, }).one() // Add posts const newPosts = await PostEntity.addItems({ items: [{ title: "New Post", content: "Content here", status: "draft" }], }).execute() // Update a post const updated = await PostEntity.updateItem({ id: "post-123", item: { status: "published" }, }).execute() ``` ## Advanced Usage ### Comparison Operators ```typescript // Greater than / Less than const recentPosts = await query(supabase, "posts", { created_at: { gte: new Date("2024-01-01") }, }).many() // Pattern matching const searchResults = await query(supabase, "posts", { title: { ilike: "%typescript%" }, }).many() // IN queries const selectedPosts = await query(supabase, "posts", { id: { in: ["id1", "id2", "id3"] }, }).many() // IS NULL checks const drafts = await query(supabase, "posts", { published_at: { is: null }, }).many() ``` ### Chaining OR Conditions ```typescript const results = await query(supabase, "users", { role: "admin" }) .or({ role: "moderator" }) .or({ role: "editor", active: true }) .many() ``` ### Soft Deletes Control soft delete behavior at the Entity or Query level: ```typescript // Entity with soft deletes enabled (excludes deleted by default) const UserEntity = Entity(supabase, "users", { softDelete: true, // Automatically filters out deleted items }) // Override soft delete behavior per query const allUsers = await UserEntity.getItems().includeDeleted().many() const deletedOnly = await UserEntity.getItems().onlyDeleted().many() const activeOnly = await UserEntity.getItems().excludeDeleted().many() // Redundant - already excluded // Entity without soft deletes (includes all items) const AdminEntity = Entity(supabase, "users", { softDelete: false, // No automatic filtering }) ``` ### Multi-Tenancy with Partition Keys Use partition keys to automatically scope queries to a tenant or partition: ```typescript // Create a tenant-scoped entity const TenantPostEntity = Entity(supabase, "posts", { softDelete: true, partitionKey: { tenant_id: "tenant-123" }, // All queries automatically include this filter }) // All queries are automatically scoped to the tenant const tenantPosts = await TenantPostEntity.getItems({ where: { status: "published" }, }).many() // Equivalent to: WHERE tenant_id = 'tenant-123' AND status = 'published' AND deleted IS NULL // Create a global/admin entity (no partition) const GlobalPostEntity = Entity(supabase, "posts", { softDelete: true, // No partitionKey - queries all tenants }) const allPosts = await GlobalPostEntity.getItems().many() // Equivalent to: WHERE deleted IS NULL ``` ### Error Handling The library uses functype's `TaskOutcome` for error handling: ```typescript // Using TaskOutcome (recommended for explicit error handling) const result = await query(supabase, "users", { id: userId }).one() if (result.isOk()) { const maybeUser = result.getOrThrow() if (maybeUser.isSome()) { console.log("User found:", maybeUser.getOrThrow()) } } else { console.error("Query failed:", result.error) } // Using OrThrow methods (simpler but throws errors) try { const user = await query(supabase, "users", { id: userId }).oneOrThrow() console.log("User:", user) } catch (error) { console.error("Query failed:", error) } ``` ### Type Safety All operations are fully type-safe based on your database schema: ```typescript // TypeScript will enforce correct field names and types const posts = await query(supabase, "posts", { // ✅ TypeScript knows these fields exist and their types title: "My Post", published: true, view_count: { gte: 100 }, // ❌ TypeScript error: property doesn't exist nonexistent_field: "value", }).many() ``` ## API Reference ### Query Methods - `one()` - Execute query expecting exactly one result - `many()` - Execute query expecting zero or more results - `first()` - Execute query expecting first result from potentially multiple - `oneOrThrow()` - Like `one()` but throws if not found - `manyOrThrow()` - Like `many()` but throws on error - `firstOrThrow()` - Like `first()` but throws if not found ### Query Composition - `or(conditions)` - Add OR conditions to the query - `map(fn)` - Transform results with a mapping function - `filter(fn)` - Filter results with a predicate - `limit(n)` - Limit the number of results - `offset(n)` - Skip the first n results - `includeDeleted()` - Include soft-deleted items in results - `excludeDeleted()` - Exclude soft-deleted items from results - `onlyDeleted()` - Return only soft-deleted items ### Entity Methods - `getItem({ id, where?, is? })` - Get a single item by ID - `getItems({ where?, is?, wherein?, order? })` - Get filtered items - `addItems({ items })` - Add multiple items - `updateItem({ id, item, where?, is?, wherein? })` - Update a single item - `updateItems({ items, identity?, where?, is?, wherein? })` - Update multiple items ### Entity Configuration - `softDelete: boolean` - (Required) When `true`, automatically excludes soft-deleted items; when `false`, includes them - `partitionKey?: Record<string, unknown>` - (Optional) Automatically applies partition filtering (e.g., `{ tenant_id: "123" }` for multi-tenancy) ## Requirements - TypeScript 5.0+ - Supabase JS Client v2 - functype 0.14+ ## License MIT ## Development ### Setup ```bash # Clone the repository git clone https://github.com/jordanburke/supabase-typed-query.git cd supabase-typed-query # Install dependencies pnpm install # Run validation pipeline pnpm validate ``` ### Commands ```bash # Development pnpm validate # Run full validation: format, lint, typecheck, test, build pnpm test # Run tests pnpm test:watch # Run tests in watch mode pnpm test:coverage # Run tests with coverage pnpm build # Build for production pnpm build:dev:watch # Build in watch mode for development # Code Quality pnpm format # Format code with Prettier pnpm lint # Fix ESLint issues pnpm typecheck # Type check with TypeScript ``` ### CI/CD Pipeline This project uses GitHub Actions for continuous integration and deployment: #### Continuous Integration - **Triggered on**: Push/PR to main/develop branches - **Test matrix**: Node.js 18.x, 20.x, 22.x - **Pipeline**: Format → Lint → Type Check → Test → Build #### Release Process 1. Update version in `package.json` 2. Create and push a version tag: ```bash git tag v1.0.0 git push origin v1.0.0 ``` 3. GitHub Actions automatically: - Runs full validation pipeline - Publishes to NPM registry - Creates GitHub release #### Security - CodeQL analysis runs on all PRs and weekly scheduled scans - Dependency updates via Dependabot ### Publishing The package is automatically published to NPM when a version tag is pushed. Manual publishing: ```bash # Ensure you're logged into NPM npm login # Run validation and publish pnpm build:publish ``` ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. ### Development Guidelines 1. **Type Safety**: Maintain strict TypeScript types, no `any` 2. **Functional Style**: Use functional programming patterns with functype 3. **Testing**: Add tests for new features 4. **Documentation**: Update README for API changes 5. **Commits**: Follow conventional commits format ### Pull Request Process 1. Fork the repository 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 3. Run `pnpm validate` to ensure all checks pass 4. Commit your changes (`git commit -m 'feat: add amazing feature'`) 5. Push to the branch (`git push origin feature/amazing-feature`) 6. Open a Pull Request Tests will automatically run via GitHub Actions on your PR.