Skip to content

benwyrosdick/js-record

Repository files navigation

js-record

A TypeScript ORM, inspired by Ruby's ActiveRecord, designed for PostgreSQL, MySQL, and SQLite running on bun.

Features

  • ActiveRecord Pattern: Models inherit from a base class, combining data access and business logic
  • Type Safety: Full TypeScript support with compile-time type checking
  • Database Agnostic: Adapter pattern allows support for multiple databases
  • Query Builder: Fluent, chainable query interface
  • Associations: Support for belongsTo, hasOne, hasMany, and many-to-many relationships
  • Validations: Comprehensive validation system with built-in and custom validators
  • Callbacks/Hooks: Lifecycle hooks for running code at specific points
  • Transactions: Full ACID transaction support
  • Schema Introspection: Inspect database schema at runtime

Installation

# using bun
bun add js-record

Quick Start

Setting Up a Database Connection

Option 1: Use the CLI Configuration (Recommended)

# Create a database configuration file
js-record config:init          # SQLite (default)
js-record config:init postgres   # PostgreSQL

# This creates config/database.ts with your adapter configuration

Option 2: Manual Configuration

PostgreSQL:

import { PostgresAdapter } from 'js-record';

const adapter = new PostgresAdapter({
  host: 'localhost',
  port: 5432,
  database: 'myapp_development',
  user: 'postgres',
  password: 'password',
});

await adapter.connect();

SQLite (Bun Native):

import { SqliteAdapter } from 'js-record';

// File-based database
const adapter = new SqliteAdapter({
  database: './myapp.db',
});

// Or use in-memory database
const adapter = new SqliteAdapter({
  database: ':memory:',
});

await adapter.connect();

Defining Models

import { Model } from 'js-record';

class User extends Model {
  id!: number;
  name!: string;
  email!: string;
  createdAt!: Date;
  updatedAt!: Date;
}

class Post extends Model {
  id!: number;
  userId!: number;
  title!: string;
  content!: string;
  published!: boolean;
  createdAt!: Date;
  updatedAt!: Date;
}

// Set database adapter for all models
Model.setAdapter(adapter);

CRUD Operations

// Create
const user = await User.create({
  name: 'John Doe',
  email: '[email protected]',
});

// Read
const foundUser = await User.find(1);
const userByEmail = await User.findBy({ email: '[email protected]' });
const allUsers = await User.all();

// Update
user.name = 'Jane Doe';
await user.save();
// or
await user.update({ name: 'Jane Doe' });

// Delete
await user.destroy();

Query Builder

// Chainable queries
const activeUsers = await User.where({ active: true }).orderBy('createdAt', 'DESC').limit(10).all();

// Complex queries
const posts = await Post.where('published = ?', true)
  .where('createdAt > ?', thirtyDaysAgo)
  .orderBy('views', 'DESC')
  .all();

// Counting
const count = await User.where({ active: true }).count();

// Existence check
const exists = await User.where({ email: '[email protected]' }).exists();

Scopes

Define reusable query conditions:

// Define scopes
Post.scope('published', query => {
  return query.where({ status: 'published' });
});

Post.scope('popular', (query, minViews = 100) => {
  return query.where('views >= ?', minViews);
});

Post.scope('recent', query => {
  return query.orderBy('createdAt', 'DESC').limit(10);
});

// Use scopes
const publishedPosts = await (Post as any).published().all();
const popularPosts = await (Post as any).popular(500).all();

// Chain scopes with queries
const featured = await (Post as any)
  .published()
  .where({ isFeatured: true })
  .orderBy('views', 'DESC')
  .all();

// Default scope (applied to all queries)
User.defaultScope({
  where: { deletedAt: null }, // Soft delete
  order: ['name', 'ASC'],
});

// Bypass default scope when needed
const allUsers = await User.unscoped().all();

Associations

Define relationships between models:

// One-to-many
User.hasMany('posts', Post);
Post.belongsTo('user', User);

// One-to-one
User.hasOne('profile', Profile);
Profile.belongsTo('user', User);

// Many-to-many
Post.hasManyThrough('tags', Tag, {
  through: 'post_tags',
  foreignKey: 'postId',
  throughForeignKey: 'tagId',
});

// Usage
const user = await User.find(1);
const posts = await user.posts.all();
const profile = await user.profile;

const post = await Post.find(1);
const author = await post.user;
const tags = await post.tags.all();

// Create associated records
await user.posts.create({
  title: 'New Post',
  content: 'Content here',
});

// Add to many-to-many
const tag = await Tag.find(1);
await post.tags.add(tag);

Validations

Define validation rules to ensure data integrity:

class User extends Model {
  static validations = {
    name: {
      presence: true,
      length: { min: 2, max: 100 },
    },
    email: {
      presence: true,
      format: {
        pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
        message: 'must be a valid email address',
      },
      uniqueness: true,
    },
    age: {
      numericality: {
        onlyInteger: true,
        greaterThanOrEqualTo: 0,
        lessThanOrEqualTo: 150,
      },
    },
    username: {
      presence: true,
      length: { min: 3, max: 20 },
      uniqueness: { caseSensitive: false },
    },
    password: {
      length: { min: 8 },
      confirmation: true, // Requires passwordConfirmation field
    },
    status: {
      inclusion: {
        in: ['active', 'inactive', 'pending'],
      },
    },
    website: {
      format: {
        pattern: /^https?:\/\/.+/,
        message: 'must be a valid URL',
      },
    },
  };
}

// Usage
const user = new User();
user.name = 'John';
user.email = 'invalid-email';

const isValid = await user.validate();
console.log(isValid); // false
console.log(user.errors); // { email: ['must be a valid email address'] }

// save() automatically validates
const saved = await user.save(); // false (validation failed)

// Skip validation if needed
await user.save({ validate: false });

Available Validations:

  • presence - Requires value to be present
  • length - Min/max/exact length for strings and arrays
  • format - Regex pattern matching
  • numericality - Number validation with constraints
  • uniqueness - Database uniqueness check
  • inclusion - Value must be in list
  • exclusion - Value must not be in list
  • confirmation - Field must match confirmation field
  • custom - Custom validation function

Callbacks/Hooks

Run code at specific points in a model's lifecycle:

class Article extends Model {
  // Instance method callbacks
  beforeSave() {
    // Generate slug from title
    if (!this.slug && this.title) {
      this.slug = this.title.toLowerCase().replace(/\s+/g, '-');
    }
  }

  afterCreate() {
    console.log('Article created!');
    // Send notifications, update cache, etc.
  }

  beforeDestroy() {
    // Clean up associated records
    return confirm('Are you sure?'); // Return false to halt
  }
}

// Class-level callbacks
User.beforeCreate('hashPassword');
User.afterCreate('sendWelcomeEmail');

// Conditional callbacks
Post.afterCreate(
  model => {
    // Send publication notification
  },
  { if: model => model.status === 'published' }
);

Available Callbacks:

  • beforeValidation / afterValidation
  • beforeSave / afterSave
  • beforeCreate / afterCreate
  • beforeUpdate / afterUpdate
  • beforeDestroy / afterDestroy

Returning false from a before* callback halts the chain and prevents the operation.

Transactions

const transaction = await adapter.beginTransaction();
try {
  await transaction.execute('INSERT INTO users (name) VALUES ($1)', ['Alice']);
  await transaction.execute('INSERT INTO profiles (userId) VALUES ($1)', [1]);
  await transaction.commit();
} catch (error) {
  await transaction.rollback();
}

Migrations

Manage your database schema with migrations:

First, set up your database configuration

js-record config:init # SQLite (default) js-record config:init postgres # PostgreSQL

Create a new migration

npx js-record migration:create add_status_to_users

Or with bun

bunx js-record migration:create add_status_to_users

Interactive mode

npx js-record migration:create

Generate initial migration from existing database schema

npx js-record migration:init

The migration:init command is useful when adding js-record to an existing project - it will introspect your current database schema and generate a migration file with all the CREATE TABLE statements.

This creates a new migration file in migrations/ with a timestamp and stub:

import { Migration } from 'js-record';

export default class AddStatusToUsers extends Migration {
  async up(): Promise<void> {
    // Add your migration code here
  }

  async down(): Promise<void> {
    // Add your rollback code here
  }
}

Writing Migrations

export default class CreateUsersTable extends Migration {
  async up(): Promise<void> {
    await this.createTable('users', table => {
      table.increments('id');
      table.string('name').notNullable();
      table.string('email').unique().notNullable();
      table.boolean('active').defaultTo(true);
      table.timestamps(); // created_at, updated_at
    });

    // Add indexes
    await this.createIndex('users', ['email']);
  }

  async down(): Promise<void> {
    await this.dropTable('users');
  }
}

Running Migrations

Using the CLI (Recommended):

First, configure your database connection by creating js-record.config.js:

module.exports = {
  adapter: 'postgres',
  host: 'localhost',
  port: 5432,
  database: 'myapp_dev',
  user: 'postgres',
  password: 'postgres',
};

Then run migrations:

# Run all pending migrations
npx js-record migrate

# Check migration status
npx js-record migrate:status

# Rollback last batch
npx js-record migrate:down

# Rollback last 2 batches
npx js-record migrate:down 2

# Reset all migrations
npx js-record migrate:reset

Programmatic API:

import { MigrationRunner } from 'js-record';

const runner = new MigrationRunner(adapter);

// Import your migrations
const migrations = new Map([
  ['20250111000001_create_users_table', CreateUsersTable],
  ['20250111000002_create_posts_table', CreatePostsTable],
]);

// Run all pending migrations
await runner.up(migrations);

// Rollback last batch
await runner.rollback(migrations, 1);

// Check migration status
const statuses = await runner.status(migrations);

// Reset all migrations
await runner.reset(migrations);

See the examples/migrations-usage.ts for more examples and CLI documentation for detailed CLI usage.

Project Status

🚧 Work in Progress - This library is under active development.

Completed

  • βœ… Base adapter interface
  • βœ… PostgreSQL adapter with connection pooling
  • βœ… Transaction support
  • βœ… Schema introspection
  • βœ… Query Builder
  • βœ… Base Model class
  • βœ… CRUD operations
  • βœ… Associations (belongsTo, hasOne, hasMany, hasManyThrough)
  • βœ… Validations (presence, length, format, numericality, uniqueness, custom, etc.)
  • βœ… Callbacks/Hooks (lifecycle hooks with conditional execution)
  • βœ… Migrations
  • βœ… SQLite adapter with Bun's native SQLite support
  • βœ… Scopes (reusable query conditions)

Planned

  • πŸ“‹ Eager loading (includes)
  • πŸ“‹ MySQL adapter

Development

This project uses Just as a command runner for common tasks.

Quick Start

# Install Just (if not already installed)
# macOS: brew install just
# Linux: curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin

# List all available commands
just --list

# Common commands
just install      # Install dependencies
just build        # Build the project
just test         # Run all tests
just quick-test   # Run fast SQLite tests
just check        # Type check, lint, and format check
just dev          # Clean, build, and test

Using npm/bun scripts (alternative)

# Install dependencies
bun install

# Build the project
bun run build

# Watch mode for development
bun run build:watch

# Type checking
bun run typecheck

# Linting
bun run lint

# Format code
bun run format

# Run tests
bun run test:sqlite

See docs/JUSTFILE.md for complete Justfile documentation.

Architecture

The library is organized into modular components:

src/
β”œβ”€β”€ adapters/       # Database adapters
β”œβ”€β”€ core/           # Core ORM functionality (coming soon)
β”œβ”€β”€ associations/   # Relationship management (coming soon)
β”œβ”€β”€ validations/    # Validation framework (coming soon)
└── callbacks/      # Hook system (coming soon)

Database Support

Currently supported:

  • PostgreSQL - Full support using Bun's native PostgreSQL driver
  • SQLite - Full support using Bun's native SQLite (file-based and in-memory)

Planned:

  • MySQL
  • SQL Server

Contributing

This is an open-source project. Contributions are welcome!

License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors