database Service
The database
service provides access to the application's database layer, offering a high-level ORM-like interface for interacting with MySQL and SQLite databases. It supports dynamic table access, relationship queries through nested object syntax, migrations, transactions, and optional multi-tenancy.
Interface
The database service provides the following interface:
Core Methods
database.run(query)
- Execute raw SQL queries (string or array format)database.migrate()
- Run database migrationsdatabase.drop()
- Drop all database tablesdatabase.reset()
- Reset database schema cache after manual schema changesdatabase.destroy()
- Close database connectiondatabase.lock(fn)
- Execute function with database lockdatabase.transaction(fn)
- Execute function within transaction
Dynamic Table Access
database[tableName]
- Access table by name (returns Table instance)database.table(name, fn)
- Create table instance with optional callback (used in migrations)database.union(name)
- Create union query across multiple tablesdatabase.singleton(name)
- Access singleton record (auto-created if missing)
Schema Information
database.info
- Object containing table/singleton mappingsdatabase.getUnixTimestamp()
- Get current database timestamp
Multi-tenant Support
database.withoutTenantScope
- Access database without tenant filtering
Table Methods
Tables accessed via database[tableName]
provide:
Query Methods
table.where(conditions)
- Add WHERE conditionstable.orderBy(column, direction)
- Add ORDER BY clausetable.paginate(page, pageSize, skipCount)
- Add pagination (page defaults to 1, pageSize to 10)table.all()
- Get all matching recordstable.first()
- Get first matching record (automatically applies LIMIT 1)table.count()
- Count matching records
CRUD Operations
table.insert(fields, options)
- Insert new recordtable.update(fields, options)
- Update matching recordstable.delete()
- Delete matching records
Dynamic Scopes
Tables automatically generate query scopes for each column that can be used within where()
conditions:
table.where({ columnName: value })
- WHERE column = valuetable.where({ columnNameNe: value })
- WHERE column != valuetable.where({ columnNameGt: value })
- WHERE column > valuetable.where({ columnNameLt: value })
- WHERE column < valuetable.where({ columnNameGe: value })
- WHERE column >= valuetable.where({ columnNameLe: value })
- WHERE column <= valuetable.where({ columnNameBeginsWith: value })
- WHERE column LIKE 'value%'table.where({ columnNameEndsWith: value })
- WHERE column LIKE '%value'table.where({ columnNameContains: value })
- WHERE column LIKE '%value%'
Array Values: When passing an array as the value, multiple conditions are created:
table.where({ columnName: [val1, val2] })
- WHERE (column = val1 OR column = val2)table.where({ columnNameNe: [val1, val2] })
- WHERE (column != val1 AND column != val2)
Relationship Queries: Use nested objects to query across relationships:
table.where({ relationshipName: { columnName: value } })
- Join with related table and filter
Examples
Basic Table Operations
// Access users table
const users = this.database.users;
// Insert a new user
const user = await this.database.users.insert({
name: 'John Doe',
email: 'john@example.com',
role: 'admin'
});
// Find users by email
const user = await this.database.users.where({ email: 'john@example.com' }).first();
// Find all admin users
const admins = await this.database.users.where({ role: 'admin' }).all();
// Update user
await this.database.users.where({ id: user.id }).update({
name: 'Jane Doe'
});
// Delete user
await this.database.users.where({ id: user.id }).delete();
Dynamic Column Scopes
// Using generated scopes for different column types
const posts = await this.database.posts
.where({ title: 'My Post' }) // WHERE title = 'My Post'
.where({ status: ['published', 'draft'] }) // WHERE (status = 'published' OR status = 'draft')
.where({ createdAtGt: '2023-01-01' }) // WHERE createdAt > '2023-01-01'
.all();
// Multiple conditions in single where() call
const users = await this.database.users
.where({
emailNe: 'admin@example.com', // WHERE email != 'admin@example.com'
role: 'user' // AND role = 'user'
})
.all();
// Or chained where() calls
const users2 = await this.database.users
.where({ emailNe: 'admin@example.com' }) // WHERE email != 'admin@example.com'
.where({ role: 'user' }) // AND role = 'user'
.all();
Real-world Dynamic Scope Examples
// Find posts excluding current post and filtering by date
const previousPost = await this.database.posts
.where({
idNe: post.id,
publishedAtLt: post.publishedAt
})
.orderBy('publishedAt', 'desc')
.first();
const nextPost = await this.database.posts
.where({
idNe: post.id,
publishedAtGt: post.publishedAt
})
.orderBy('publishedAt', 'asc')
.first();
// Find users excluding current user with admin role
const adminUsers = await this.database.users
.where({
idNe: currentUserId,
role: 'admin'
})
.all();
// Complex validation queries
const duplicateSlug = await this.database.pageables
.where({
idNe: pageable.id,
slug: pageable.slug
})
.count();
Pagination and Ordering
// Get paginated results
const posts = await this.database.posts
.orderBy('createdAt', 'desc')
.paginate(2, 10) // page 2, 10 items per page
.all();
// Count total records
const totalPosts = await this.database.posts.count();
// Get first record
const latestPost = await this.database.posts
.orderBy('createdAt', 'desc')
.first();
Raw SQL Queries
// Execute raw SQL
const results = await this.database.run(`
SELECT u.name, COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON u.id = p.userId
GROUP BY u.id
`);
// For parameterized queries, use the built-in query builder syntax
const user = await this.database.users.where({ email: 'john@example.com' }).first();
Transactions
// Execute operations within a transaction
await this.database.transaction(async () => {
const user = await this.database.users.insert({
name: 'John Doe',
email: 'john@example.com'
});
await this.database.posts.insert({
userId: user.id,
title: 'First Post',
body: 'Hello world!'
});
// If any operation fails, entire transaction is rolled back
});
Database Locks
// Execute with database lock (prevents concurrent access)
await this.database.lock(async () => {
const counter = await this.database.settings.value;
await this.database.settings.update({
value: counter + 1
});
});
Singleton Records
// Access singleton record (auto-created if missing)
const site = await this.database.site;
// Access singleton properties
const siteTitle = await this.database.site.title;
const siteDescription = await this.database.site.description;
// Update singleton
await this.database.site.update({
title: 'My Website',
description: 'Welcome to my site'
});
Multi-tenant Operations
// Access current tenant's data (when multi-tenancy enabled)
const posts = await this.database.posts.all(); // Automatically scoped to current tenant
// Access data without tenant scope
const allPosts = await this.database.withoutTenantScope.posts.all();
Database Migrations
// Run pending migrations
await this.database.migrate();
// Drop all tables (destructive!)
await this.database.drop();
// Reset schema cache (after manual schema changes)
await this.database.reset();
Schema Inspection
// Get database schema information
console.log(this.database.info);
// Output: {
// users: 'table',
// posts: 'table',
// tags: 'table',
// site: 'singleton',
// ...
// }
// Check if table exists
if (this.database.info.posts === 'table') {
// Posts table exists
}
Complex Queries with Relationships
// Using nested object syntax for relationship queries
const postsWithAppleTags = await this.database.posts
.where({ tags: { name: 'Apple' } })
.all();
// Multiple relationship conditions
const postsWithMultipleTags = await this.database.posts
.where({ tags: { name: 'Apple' } })
.where({ tags: { name: 'Orange' } })
.all();
// Accessing related data through model relationships
const post = await this.database.posts.where({ id: postId }).first();
if (post) {
// Access related tags (if relationship defined in model)
const tags = await post.tags;
const author = await post.user;
}
Table Creation (Migrations)
// Using database.table() in migrations for table creation and modification
export default {
async migrate(){
await this.database.table('posts', async (posts) => {
await posts.addColumn('title', 'string');
await posts.addColumn('body', 'text');
await posts.addColumn('publishedAt', 'datetime', { index: true });
});
}
};
// Note: Tables are automatically created when the first column is added
// In most other cases, use direct table access:
// const posts = this.database.posts; // Preferred for normal operations
Error Handling
try {
await this.database.transaction(async () => {
await this.database.users.insert({
email: 'duplicate@example.com' // May violate unique constraint
});
});
} catch (error) {
console.error('Database operation failed:', error.message);
// Transaction automatically rolled back on error
}
Background Jobs and Database
// Safe database access in background jobs
export default {
async run() {
// Delete expired tokens using generated scope
await this.database.usedHashes
.where({ expiresAtLt: new Date() })
.delete();
}
};
Database Configuration
The database service is configured through the application's config:
// pinstripe.config.js
export default {
database: {
// SQLite configuration
adapter: 'sqlite',
filename: './development.db'
// Or MySQL configuration
// adapter: 'mysql',
// host: 'localhost',
// user: 'username',
// password: 'password',
// database: 'myapp'
}
};
Notes
- The database service automatically handles connection pooling and cleanup
- Insert, update, and delete operations are automatically wrapped in transactions
- Column scopes are dynamically generated based on database schema at runtime
- Multi-tenancy is optional and configured through the
@pinstripe/multi-tenant
package - The service supports both MySQL and SQLite databases with adapter-specific optimizations
- Schema changes require calling
database.reset()
to refresh the schema cache - The
database.table(name, fn)
method is used in migrations; tables are automatically created when the first column is added