better-query
v0.1.0
Published
Type-safe CRUD generator with auto-generated REST APIs for Node.js applications
Downloads
10
Maintainers
Readme
Better Query
A standalone, type-safe CRUD generator built on top of better-call that follows the architecture patterns of better-auth. Generate REST API endpoints for your resources with minimal configuration.
Features
- 🚀 CLI Generator: Get started instantly with
npx better-query init --with-auth - 🔧 Automatic Endpoint Generation: Creates full CRUD endpoints for any resource
- ✅ Type-Safe: Full TypeScript support with Zod schema validation
- 🔐 Better Auth Integration: Native integration with Better Auth for authentication and authorization
- 🔒 Granular Permissions: Configure permissions per operation (create, read, update, delete, list)
- 🛡️ Role-Based Security: Define permissions based on user roles and organization membership
- 🔄 Schema Migrations: Automated detection and handling of breaking schema changes
- 🎛️ Configurable: Enable/disable specific endpoints per resource
- 📊 Pagination: Built-in pagination support for list endpoints
- 🔍 Search: Basic search functionality for list operations
- 🏗️ Database Agnostic: Works with SQLite, PostgreSQL, and MySQL via Kysely
- 🌐 Framework Agnostic: Works with any framework that supports Web API handlers
- 🎯 Type-Safe Client: Client SDK with full TypeScript support, similar to better-auth
- 🎨 Type Generation: Automatic TypeScript type generation from your schemas
Quick Start with CLI
The fastest way to get started is using the Better Query CLI:
# Create a new Next.js project with Better Auth
npx better-query init --with-auth
# Create a basic project
npx better-query init
# Navigate to your project and install dependencies
cd my-project
npm install
# Start development
npm run devFor complete CLI documentation, see CLI.md.
Installation
npm install better-query
# or
yarn add better-query
# or
pnpm add better-queryDatabase Dependencies
Install the appropriate database driver for your provider:
# For SQLite
npm install better-sqlite3
# For PostgreSQL
npm install pg @types/pg
# For MySQL
npm install mysql2Quick Start
1. Basic Setup
import { betterQuery, createResource, withId } from "better-query";
import { z } from "zod";
// Define your custom schema
const productSchema = withId({
name: z.string().min(1, "Product name is required"),
description: z.string().optional(),
price: z.number().min(0, "Price must be positive"),
status: z.enum(["active", "inactive", "draft"]).default("draft"),
});
export const query = betterQuery({
resources: [
createResource({
name: "product",
schema: productSchema,
permissions: {
create: () => true, // Allow all creates
read: () => true, // Allow all reads
update: () => true, // Allow all updates
delete: () => false, // Disallow all deletes
list: () => true, // Allow all lists
},
}),
],
database: {
provider: "sqlite",
url: "sqlite:./database.db",
autoMigrate: true,
},
});2. Client Setup (NEW!)
Create a type-safe client for your CRUD operations:
import { createQueryClient } from "better-query";
// Create the client with type inference from your query instance
export const queryClient = createQueryClient<typeof query>({
baseURL: "http://localhost:3000/api",
});
// Now you can use the client with full type safety:
await queryClient.product.create({
name: "Tee shirt",
price: 29.99,
}, {
headers: {
"Authorization": "Bearer your-token",
}
});TypeScript Auto-completion & Type Safety
The client provides full TypeScript support similar to better-auth:
// ✅ Auto-completion for all resources and methods
crudClient.product.create({ /* schema-based suggestions */ });
crudClient.category.list({ /* typed parameters */ });
// ✅ Schema validation - TypeScript enforces required fields
await crudClient.product.create({
name: "Required field", // ✅ TypeScript enforces this
price: 29.99, // ✅ Validates number type
status: "active", // ✅ Enum validation
// description: "optional" // ✅ Optional fields suggested
});
// ✅ Partial updates with proper typing
await crudClient.product.update("id", {
price: 34.99, // ✅ Only update fields you want to change
});
// ✅ Properly typed responses
const result = await crudClient.product.create(data);
if (result.error) {
console.log(result.error.code); // ✅ Typed error codes
} else {
console.log(result.data.name); // ✅ Typed response data
}Error Handling
Better Query includes error codes similar to better-auth:
// Access error codes
console.log(queryClient.$ERROR_CODES.VALIDATION_FAILED);
console.log(queryClient.$ERROR_CODES.FORBIDDEN);
console.log(queryClient.$ERROR_CODES.NOT_FOUND);
// Typed error handling pattern
type ErrorTypes = Partial<
Record<
keyof typeof queryClient.$ERROR_CODES,
{
en: string;
es: string;
}
>
>;
const errorCodes = {
VALIDATION_FAILED: {
en: "validation failed",
es: "validación fallida",
},
FORBIDDEN: {
en: "access denied",
es: "acceso denegado",
},
} satisfies ErrorTypes;
const getErrorMessage = (code: string, lang: "en" | "es") => {
if (code in errorCodes) {
return errorCodes[code as keyof typeof errorCodes][lang];
}
return "";
};
// Usage in components
const result = await queryClient.product.create(data);
if (result.error?.code) {
alert(getErrorMessage(result.error.code, "en"));
}Better Auth Integration
Better Query integrates with Better Auth through resource permissions and middleware:
import { betterAuth } from "better-auth";
import { betterQuery, createResource } from "better-query";
// 1. Setup Better Auth
const auth = betterAuth({
database: { provider: "sqlite", url: "auth.db" },
secret: process.env.BETTER_AUTH_SECRET,
emailAndPassword: { enabled: true },
});
// 2. Integrate with Better Query using permissions
const query = betterQuery({
basePath: "/api/query",
database: { provider: "sqlite", url: "data.db", autoMigrate: true },
resources: [
createResource({
name: "product",
schema: productSchema,
// Add middleware to extract user from Better Auth
middlewares: [
{
handler: async (context) => {
const session = await auth.api.getSession({
headers: context.request.headers,
});
if (session) context.user = session.user;
}
}
],
// Use permissions with Better Auth user context
permissions: {
create: async (context) => !!context.user, // Authenticated users only
update: async (context) => {
const user = context.user as { role?: string; id: string };
return user?.role === "admin" ||
context.existingData?.createdBy === user?.id;
},
}
})
]
});For detailed Better Auth integration guide, see Better Auth Integration Documentation.
3. Framework Integration
Next.js App Router
// app/api/[...query]/route.ts
import { query } from "@/lib/query";
export const GET = query.handler;
export const POST = query.handler;
export const PATCH = query.handler;
export const DELETE = query.handler;Hono
import { Hono } from "hono";
import { query } from "./query";
const app = new Hono();
app.all("/api/*", async (c) => {
const response = await query.handler(c.req.raw);
return response;
});Express
import express from "express";
import { crud } from "./crud";
const app = express();
app.all("/api/*", async (req, res) => {
const request = new Request(`${req.protocol}://${req.get('host')}${req.originalUrl}`, {
method: req.method,
headers: req.headers as any,
body: req.method !== 'GET' ? JSON.stringify(req.body) : undefined,
});
const response = await crud.handler(request);
const data = await response.json();
res.status(response.status).json(data);
});4. Generated Endpoints
For each resource, the following endpoints are automatically created:
POST /api/product- Create a productGET /api/product/:id- Get a product by IDPATCH /api/product/:id- Update a productDELETE /api/product/:id- Delete a productGET /api/products- List products (with pagination)
Client Usage
The client provides a convenient, type-safe way to interact with your CRUD API:
Creating Resources
// Type-safe creation with custom headers
const product = await queryClient.products.create({
name: "Awesome T-Shirt",
price: 29.99,
description: "High quality cotton shirt",
status: "active",
}, {
headers: {
"Authorization": "Bearer your-token",
"Content-Type": "application/json",
}
});Reading Resources
// Get a specific product
const product = await queryClient.products.read("product-id", {
headers: {
"Authorization": "Bearer your-token",
}
});Updating Resources
// Partial updates are supported
const updatedProduct = await queryClient.products.update("product-id", {
price: 24.99,
status: "active",
}, {
headers: {
"Authorization": "Bearer your-token",
}
});Deleting Resources
await queryClient.products.delete("product-id", {
headers: {
"Authorization": "Bearer your-token",
}
});Listing Resources with Pagination
const result = await queryClient.products.list({
page: 1,
limit: 10,
search: "shirt",
sortBy: "name",
sortOrder: "asc",
}, {
headers: {
"Authorization": "Bearer your-token",
}
});
console.log(result.data);
// {
// items: [...products],
// pagination: {
// page: 1,
// limit: 10,
// total: 25,
// totalPages: 3,
// hasNext: true,
// hasPrev: false
// }
// }React Hook Example
import { useState, useEffect } from 'react';
import { queryClient } from '@/lib/query-client';
export function useProducts() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchProducts = async (params = {}) => {
setLoading(true);
setError(null);
try {
const result = await queryClient.products.list(params, {
headers: {
"Authorization": `Bearer ${getAuthToken()}`,
}
});
if (result.error) {
throw new Error(result.error);
}
setProducts(result.data.items);
return result.data;
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
throw err;
} finally {
setLoading(false);
}
};
const createProduct = async (productData) => {
try {
const result = await queryClient.products.create(productData, {
headers: {
"Authorization": `Bearer ${getAuthToken()}`,
}
});
if (result.error) {
throw new Error(result.error);
}
const newProduct = result.data;
setProducts(prev => [...prev, newProduct]);
return newProduct;
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
throw err;
}
};
useEffect(() => {
fetchProducts();
}, []);
return { products, loading, error, fetchProducts, createProduct };
}Configuration
Resource Configuration
interface CrudResourceConfig {
name: string; // Resource name (e.g., "product")
schema: ZodSchema; // Zod validation schema
tableName?: string; // Custom table name (defaults to name)
endpoints?: { // Enable/disable specific endpoints
create?: boolean;
read?: boolean;
update?: boolean;
delete?: boolean;
list?: boolean;
};
permissions?: { // Permission functions
create?: (context: CrudPermissionContext) => Promise<boolean> | boolean;
read?: (context: CrudPermissionContext) => Promise<boolean> | boolean;
update?: (context: CrudPermissionContext) => Promise<boolean> | boolean;
delete?: (context: CrudPermissionContext) => Promise<boolean> | boolean;
list?: (context: CrudPermissionContext) => Promise<boolean> | boolean;
};
}Client Configuration
interface CrudClientOptions {
baseURL?: string; // API base URL
headers?: Record<string, string>; // Default headers for all requests
// ... other BetterFetchOption properties
}CRUD Options
interface CrudOptions {
resources: CrudResourceConfig[]; // Array of resources
database: CrudDatabaseConfig; // Database configuration
basePath?: string; // Base path for all endpoints (optional)
requireAuth?: boolean; // Global auth requirement (default: false)
middlewares?: CrudMiddleware[]; // Custom middleware
}Custom Schemas
Define your own schemas using Zod:
import { z } from "zod";
const userSchema = z.object({
id: z.string().optional(),
email: z.string().email(),
name: z.string().min(1),
role: z.enum(["user", "admin"]).default("user"),
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
});
const crud = adiemus({
resources: [
createResource({
name: "user",
schema: userSchema,
}),
],
database: {
provider: "sqlite",
url: "sqlite:./users.db",
},
});
// Client automatically infers the schema
const client = createCrudClient<typeof crud>();Schema Helpers
Adiemus provides helpful utilities for creating schemas:
import { withId, withTimestamps } from "adiemus";
import { z } from "zod";
// Helper for creating schemas with id and timestamps
const productSchema = withId({
name: z.string().min(1),
price: z.number().positive(),
description: z.string().optional(),
status: z.enum(["active", "inactive"]).default("active"),
});
// Helper for creating schemas with just timestamps
const logSchema = withTimestamps({
level: z.enum(["info", "warn", "error"]),
message: z.string(),
source: z.string(),
});
// For maximum flexibility, define schemas manually
const userSchema = z.object({
id: z.string().optional(),
email: z.string().email(),
name: z.string().min(1),
role: z.enum(["user", "admin"]).default("user"),
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
});Advanced Usage
Custom Permissions
createResource({
name: "order",
schema: orderSchema,
permissions: {
create: async (context) => {
// Only allow users to create orders for themselves
return context.user && context.data.userId === context.user.id;
},
read: async (context) => {
// Users can only read their own orders, admins can read all
if (context.user?.role === 'admin') return true;
const order = await findOrder(context.id);
return order?.userId === context.user?.id;
},
update: async (context) => {
// Only admin users can update orders
return context.user?.role === 'admin';
},
delete: () => false, // No one can delete orders
list: async (context) => {
return !!context.user; // Only authenticated users can list
},
},
})Custom Table Names
createResource({
name: "product",
schema: productSchema, // Your custom schema
tableName: "custom_products_table",
})Selective Endpoints
createResource({
name: "user",
schema: userSchema,
endpoints: {
create: true,
read: true,
update: true,
delete: false, // Disable delete endpoint
list: true,
},
})Base Path
const crud = adiemus({
resources: [/* ... */],
database: {/* ... */},
basePath: "/api/v1", // All endpoints will be prefixed with /api/v1
});Database Support
Adiemus supports multiple database providers through Kysely:
- SQLite: Perfect for development and small applications
- PostgreSQL: Production-ready with advanced features
- MySQL: Wide compatibility and good performance
Auto-Migration
Enable auto-migration to automatically create tables based on your schemas:
const crud = adiemus({
resources: [/* ... */],
database: {
provider: "sqlite",
url: "sqlite:./database.db",
autoMigrate: true, // Creates tables automatically
},
});Error Handling
Adiemus returns standard HTTP status codes:
200- Success201- Created400- Bad Request (validation errors)401- Unauthorized403- Forbidden (permission denied)404- Not Found500- Internal Server Error
The client automatically handles these responses:
try {
const product = await crudClient.products.create({
name: "Test Product",
price: 29.99,
});
if (product.error) {
// Handle API errors
console.error('API Error:', product.error);
} else {
// Success - use product.data
console.log('Created:', product.data);
}
} catch (error) {
// Handle network/other errors
console.error('Network Error:', error);
}Type Safety
The TypeScript integration provides full type safety:
// Types are automatically inferred from your custom schemas
type Product = z.infer<typeof productSchema>;
// CRUD instance is fully typed
const crud = adiemus({...});
// crud.api.createProduct, crud.api.getProduct, etc. are all typed
// Client is fully typed based on your CRUD configuration
const client = createCrudClient<typeof crud>();
// client.products.create, client.products.list, etc. are all typedEnvironment Variables
The client can automatically infer the base URL from environment variables:
# Development
BETTER_QUERY_URL=http://localhost:3000/api
# or
NEXT_PUBLIC_BETTER_QUERY_URL=http://localhost:3000/api
# Production
VERCEL_URL=https://your-app.vercel.app/api
# or
NEXT_PUBLIC_VERCEL_URL=https://your-app.vercel.app/apiContributing
Better Query follows the patterns established by better-auth. When contributing:
- Follow the existing code style
- Add tests for new functionality
- Update documentation
- Ensure TypeScript compatibility
- Test with multiple database adapters
License
MIT
