Relational Database
This page covers the relational database tools available in Powerhouse applications, providing type-safe database operations with real-time updates through PGlite integration.
Overview
The relational database layer gives you powerful tools to work with data in your Powerhouse applications. You get type-safe queries, real-time updates, and a simple API that feels familiar to React developers.
Key Benefits:
- Type-safe queries with full TypeScript support
- Live query capabilities with real-time updates
- Automatic optimization to prevent infinite re-renders
- Simple API that abstracts away complexity
- Smart memorization for parameters and queries
Quick Start
Setting up your first relational database query
Step 1: Define your database schema
type MyDatabase = {
users: {
id: number;
name: string;
email: string;
};
posts: {
id: number;
title: string;
content: string;
author_id: number;
};
};
Step 2: Create a typed query hook
import { createProcessorQuery } from "@powerhousedao/reactor-browser/relational";
import { MyProcessor } from "./processors/my-processor";
// Create a typed query hook for your processor
const useTypedQuery = createProcessorQuery(MyProcessor);
Step 3: Use it in your component
// Simple query - no parameters needed
export function useUserList(driveId: string) {
return useTypedQuery(driveId, (db) => {
return db.selectFrom("users").selectAll().compile();
});
}
// Query with parameters
export function useUserById(driveId: string, userId: number) {
return useTypedQuery(
driveId,
(db, params) => {
return db
.selectFrom("users")
.selectAll()
.where("id", "=", params.userId)
.compile();
},
{ userId },
);
}
Step 4: Use in your React component
function UserList({ driveId }: { driveId: string }) {
const { isLoading, error, result } = useUserList(driveId);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!result) return <div>No data</div>;
return (
<ul>
{result.rows.map(user => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
);
}
Core Hooks
1. createProcessorQuery()
createProcessorQuery(ProcessorClass)
: Creates a typed query hook factory for your processor
Function Name and Signature
function createProcessorQuery<Schema>(
ProcessorClass: RelationalDbProcessorClass<Schema>,
): TypedQueryHook<Schema>;
Description
Creates a typed query hook factory for a specific processor class. This is the main function you'll use to create hooks for querying your relational database.
Usage Example
import { createProcessorQuery } from "@powerhousedao/reactor-browser/relational";
import { MyProcessor } from "./processors/my-processor";
// Create a typed query hook for your processor
const useTypedQuery = createProcessorQuery(MyProcessor);
// Use it to create specific query hooks
export const useUsers = (driveId: string) => {
return useTypedQuery(driveId, (db) => {
return db.selectFrom("users").selectAll().compile();
});
};
// With parameters
export const useUsersByStatus = (driveId: string, status: string) => {
return useTypedQuery(
driveId,
(db, params) => {
return db
.selectFrom("users")
.selectAll()
.where("status", "=", params.status)
.compile();
},
{ status },
);
};
Parameters
The returned hook accepts:
driveId
: The ID of the drivequeryCallback
: Function that receives the database instance and optional parametersparameters
: Optional parameters for the query
Return Value
{
isLoading: boolean; // True while loading or retrying
error: Error | null; // Any error that occurred
result: LiveQueryResults<T> | null; // Query results with live updates
}
Notes / Caveats
- Create one
useTypedQuery
hook per processor - The hook includes automatic retry logic for common errors
- Parameters are automatically memoized
- Queries are live and will update automatically when data changes
2. useRelationalDb()
useRelationalDb<Schema>()
: Access the enhanced database instance directly
Hook Name and Signature
function useRelationalDb<Schema>(): IRelationalDb<Schema>;
Description
Provides direct access to the enhanced Kysely database instance with live query capabilities. Use this when you need to perform relational database operations outside of the typical query patterns.
Usage Example
import { useRelationalDb } from '@powerhousedao/reactor-browser/relational';
function DatabaseOperations() {
const { db, isLoading, error } = useRelationalDb<MyDatabase>();
const createUser = async (name: string, email: string) => {
if (!db) return;
// Direct database operations
await db
.insertInto('users')
.values({ name, email })
.execute();
};
if (isLoading) return <div>Database initializing...</div>;
if (error) return <div>Database error: {error.message}</div>;
return (
<button onClick={() => createUser('John', 'john@example.com')}>
Create User
</button>
);
}
Parameters
Schema
- TypeScript type defining your database schema
Return Value
{
db: EnhancedKysely<Schema> | null; // Enhanced Kysely instance with live capabilities
isLoading: boolean; // True while database is initializing
error: Error | null; // Any initialization error
}
Notes / Caveats
- Always check if
db
is not null before using it - The database instance includes both Kysely methods and live query capabilities
- Use this for direct database operations like inserts, updates, and deletes
- For queries, prefer
createProcessorQuery()
which provides better optimization
Related Hooks
createProcessorQuery
- For optimized queriesuseRelationalQuery
- For manual query control
3. useRelationalQuery()
useRelationalQuery<Schema, T, TParams>()
: Lower-level hook for manual query control
Hook Name and Signature
function useRelationalQuery<Schema, T, TParams>(
queryCallback: (
db: EnhancedKysely<Schema>,
parameters?: TParams,
) => QueryCallbackReturnType,
parameters?: TParams,
): QueryResult<T>;
Description
Lower-level hook for creating live queries with manual control over the query callback and parameters. Most developers should use createProcessorQuery()
instead, but this hook is useful for advanced use cases.
Usage Example
import { useRelationalQuery } from '@powerhousedao/reactor-browser/relational';
function UserCount() {
const { result, isLoading, error } = useRelationalQuery<MyDatabase, { count: number }>(
(db) => {
return db
.selectFrom('users')
.select(db.fn.count('id').as('count'))
.compile();
}
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>User count: {result?.rows[0]?.count ?? 0}</div>;
}
Parameters
queryCallback
- Function that receives the database instance and optional parametersparameters
- Optional parameters for the query
Return Value
{
result: LiveQueryResults<T> | null; // Live query results
isLoading: boolean; // Combined loading state
error: Error | null; // Any error that occurred
}
Notes / Caveats
- This hook doesn't include automatic parameter memoization
- Use
createProcessorQuery()
for better developer experience and optimization - Useful for cases where you need manual control over the query lifecycle
Related Hooks
createProcessorQuery
- Recommended higher-level APIuseRelationalDb
- For direct database access
Advanced Patterns
Working with Dynamic Parameters
How to handle parameters that change over time
Problem
You need to create queries that update automatically when search terms, filters, or other parameters change.
Solution
The createProcessorQuery
hook automatically handles parameter changes and memoizes them using deep comparison:
function useSearchResults() {
const [searchTerm, setSearchTerm] = useState("");
const [category, setCategory] = useState("all");
// Query automatically updates when searchTerm or category changes
const result = useTypedQuery(
(db, params) => {
let query = db.selectFrom("products").selectAll();
if (params.searchTerm) {
query = query.where("name", "like", `%${params.searchTerm}%`);
}
if (params.category !== "all") {
query = query.where("category", "=", params.category);
}
return query.compile();
},
{ searchTerm, category },
);
return { result, setSearchTerm, setCategory };
}
Key Points
- Parameters are automatically memoized using deep comparison
- No need to wrap parameters in
useMemo
- Query re-runs only when parameter values actually change
- Works with complex nested objects
Custom SQL Queries
Using raw SQL instead of Kysely query builder
Problem
You need to write complex SQL queries that are easier to express in raw SQL than using the Kysely query builder.
Solution
You can return raw SQL queries from your callback:
function useCustomUserStats() {
return useTypedQuery(() => {
return {
sql: `
SELECT
u.name,
COUNT(p.id) as post_count,
MAX(p.created_at) as last_post_date
FROM users u
LEFT JOIN posts p ON u.id = p.author_id
GROUP BY u.id, u.name
ORDER BY post_count DESC
`,
};
});
}
// With parameters
function useUserPostsByDateRange(startDate: string, endDate: string) {
return useTypedQuery(
(db, params) => {
return {
sql: `
SELECT p.*, u.name as author_name
FROM posts p
JOIN users u ON p.author_id = u.id
WHERE p.created_at BETWEEN $1 AND $2
ORDER BY p.created_at DESC
`,
parameters: [params.startDate, params.endDate],
};
},
{ startDate, endDate },
);
}
Key Points
- Return an object with
sql
and optionalparameters
properties - Use parameterized queries ($1, $2, etc.) for dynamic values
- You can mix Kysely and raw SQL approaches in the same application
Complex Joins and Relationships
Working with related data across multiple tables
Problem
You need to fetch related data from multiple tables with complex relationships.
Solution
Use Kysely's join capabilities within your query callbacks:
function useUsersWithPosts() {
return useTypedQuery((db) => {
return db
.selectFrom("users")
.leftJoin("posts", "users.id", "posts.author_id")
.select([
"users.id",
"users.name",
"users.email",
"posts.title as post_title",
"posts.content as post_content",
])
.compile();
});
}
// More complex example with multiple joins and aggregations
function useUserDashboardData(userId: number) {
return useTypedQuery(
(db, params) => {
return db
.selectFrom("users")
.leftJoin("posts", "users.id", "posts.author_id")
.leftJoin("comments", "posts.id", "comments.post_id")
.select([
"users.id",
"users.name",
"users.email",
db.fn.count("posts.id").as("post_count"),
db.fn.count("comments.id").as("comment_count"),
])
.where("users.id", "=", params.userId)
.groupBy(["users.id", "users.name", "users.email"])
.compile();
},
{ userId },
);
}
Key Points
- Use Kysely's join methods for related data
- Leverage aggregation functions for counts and calculations
- Type safety is maintained throughout complex queries
Best Practices
1. Schema Definition
How to properly define your database schema types
Always define clear TypeScript interfaces for your database schema:
// ✅ Good - Clear, typed schema
type AppDatabase = {
users: {
id: number;
name: string;
email: string;
created_at: Date;
updated_at: Date;
};
posts: {
id: number;
title: string;
content: string;
author_id: number;
published: boolean;
created_at: Date;
};
};
// ❌ Avoid - Vague or missing types
type BadDatabase = {
users: any;
posts: Record<string, unknown>;
};
2. Hook Organization
How to organize your database hooks
Create focused, reusable hooks for different data access patterns:
// ✅ Good - Focused, reusable hooks
export function useUsers() {
return useTypedQuery((db) => db.selectFrom("users").selectAll().compile());
}
export function useUserById(id: number) {
return useTypedQuery(
(db, params) =>
db.selectFrom("users").selectAll().where("id", "=", params.id).compile(),
{ id },
);
}
export function useActiveUsers() {
return useTypedQuery((db) =>
db.selectFrom("users").selectAll().where("active", "=", true).compile(),
);
}
// ❌ Avoid - Too generic or complex
export function useEverything() {
return useTypedQuery((db) =>
db
.selectFrom("users")
.leftJoin("posts", "users.id", "posts.author_id")
.leftJoin("comments", "posts.id", "comments.post_id")
.selectAll() // Too much data
.compile(),
);
}
3. Error Handling
How to handle loading states and errors
Always handle loading and error states in your components:
function UserList() {
const { isLoading, error, result } = useUsers();
// ✅ Good - Handle all states
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
if (!result) return <NoDataMessage />;
return (
<ul>
{result.rows.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// ❌ Avoid - Missing error handling
function BadUserList() {
const { result } = useUsers();
return (
<ul>
{result?.rows.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
4. Performance Optimization
Tips for optimal query performance
- Keep queries focused: Don't select unnecessary columns or join too many tables
- Use parameters wisely: The automatic memoization handles most cases, but avoid creating new objects unnecessarily
- Consider query frequency: For data that changes rarely, consider caching strategies
// ✅ Good - Focused query
function useUserNames() {
return useTypedQuery((db) =>
db
.selectFrom("users")
.select(["id", "name"]) // Only what you need
.compile(),
);
}
// ✅ Good - Stable parameters
function useUsersByStatus(status: string) {
return useTypedQuery(
(db, params) =>
db
.selectFrom("users")
.selectAll()
.where("status", "=", params.status)
.compile(),
{ status }, // Simple, stable parameter
);
}
// ❌ Avoid - Unnecessary data
function useEverythingAboutUsers() {
return useTypedQuery((db) =>
db
.selectFrom("users")
.leftJoin("posts", "users.id", "posts.author_id")
.selectAll() // Too much data
.compile(),
);
}