A powerful ORM-like library (v0.3.0) for Cloudflare Workers D1 with validation support via Joi.
- Type-safe models with TypeScript support
- Basic CRUD operations with a PostgreSQL-like interface
- Enhanced column types including string, number, boolean, date, and JSON
- UUID generation by default for primary keys
- Automatic timestamps for created_at and updated_at fields
- Soft delete functionality for non-destructive record removal
- Data serialization and deserialization for complex data types
- Form validation powered by Joi
- Raw SQL query support for complex operations
- Proper data encapsulation through the data property pattern
- Installation
- Quick Start
- Model Definition
- Schema Configuration
- Column Types and Constraints
- Form Validation
- CRUD Operations
- Soft Delete
- Extending Models
- TypeScript Support
npm:
npm install model-one joi
yarn:
yarn add model-one joi
import { Model, Schema, Form } from 'model-one';
import Joi from 'joi';
// Define schema
const userSchema = new Schema({
table_name: 'users',
columns: [
{ name: 'id', type: 'string' },
{ name: 'name', type: 'string' },
{ name: 'email', type: 'string' },
{ name: 'preferences', type: 'jsonb' }
],
timestamps: true,
softDeletes: true
});
// Define validation schema
const joiSchema = Joi.object({
id: Joi.string(),
name: Joi.string().required(),
email: Joi.string().email().required(),
preferences: Joi.object()
});
// Define interfaces
interface UserDataI {
id?: string;
name?: string;
email?: string;
preferences?: Record<string, any>;
}
interface UserI extends Model {
data: UserDataI;
}
// Create form class
class UserForm extends Form {
constructor(data: UserI) {
super(joiSchema, data);
}
}
// Create model class
class User extends Model implements UserI {
data: UserDataI;
constructor(props: UserDataI = {}) {
super(userSchema);
this.data = props || {};
}
}
// Usage example
async function createUser(env) {
const userData = { name: 'John Doe', email: '[email protected]', preferences: { theme: 'dark' } };
const user = new User(userData);
const form = new UserForm(user);
const createdUser = await User.create(form, env.DB);
console.log(createdUser.data.id); // Auto-generated UUID
console.log(createdUser.data.name); // 'John Doe'
console.log(createdUser.data.preferences.theme); // 'dark'
}
Models in Model-One follow a specific pattern to ensure type safety and proper data encapsulation:
// Define your data interface
interface EntityDataI {
id?: string;
// Add your custom properties here
name?: string;
// etc...
}
// Define your model interface that extends the base Model
interface EntityI extends Model {
data: EntityDataI;
}
// Create your model class
class Entity extends Model implements EntityI {
data: EntityDataI;
constructor(props: EntityDataI = {}) {
super(entitySchema);
this.data = props || {};
}
}
In Model-One v0.2.0 and above, all entity properties must be accessed through the data
property:
// Correct way to access properties
const user = await User.findById(id, env.DB);
if (user) {
console.log(user.data.name); // Correct
console.log(user.data.email); // Correct
}
// Incorrect way (will not work)
console.log(user.name); // Incorrect
console.log(user.email); // Incorrect
yarn add model-one joi
The Schema class is used to define your database table structure:
const entitySchema = new Schema({
table_name: 'entities', // Name of the database table
columns: [
{ name: 'id', type: 'string' }, // Primary key (UUID by default)
{ name: 'title', type: 'string' },
{ name: 'count', type: 'number' },
{ name: 'is_active', type: 'boolean' },
{ name: 'metadata', type: 'jsonb' },
{ name: 'published_at', type: 'date' }
],
timestamps: true, // Adds created_at and updated_at columns
softDeletes: true // Adds deleted_at column for soft deletes
});
Model-One supports the following column types:
Type | JavaScript Type | Description |
---|---|---|
string |
string |
Text data |
number |
number |
Numeric data |
boolean |
boolean |
Boolean values (true/false) |
date |
Date |
Date and time values |
jsonb |
object or array |
JSON data that is automatically serialized/deserialized |
Model-One uses Joi for form validation:
import Joi from 'joi';
import { Form } from 'model-one';
// Define validation schema
const joiSchema = Joi.object({
id: Joi.string(),
title: Joi.string().required().min(3).max(100),
count: Joi.number().integer().min(0),
is_active: Joi.boolean(),
metadata: Joi.object(),
published_at: Joi.date()
});
// Create form class
class EntityForm extends Form {
constructor(data: EntityI) {
super(joiSchema, data);
}
}
// Usage
const entity = new Entity({ title: 'Test' });
const form = new EntityForm(entity);
// Validation happens automatically when creating or updating
const createdEntity = await Entity.create(form, env.DB);
Model-One provides the following CRUD operations:
// Create a new entity
const entity = new Entity({ title: 'New Entity', count: 42 });
const form = new EntityForm(entity);
const createdEntity = await Entity.create(form, env.DB);
// Access the created entity's data
console.log(createdEntity.data.id); // Auto-generated UUID
console.log(createdEntity.data.title); // 'New Entity'
Model-One provides several static methods on your model class to retrieve records from the database. All these methods return model instances (or null
/ an array of instances), and you should access their properties via the .data
object.
-
YourModel.findById(id: string, env: any, includeDeleted?: boolean): Promise<YourModel | null>
Finds a single record by its ID. Returns a model instance or
null
if not found.const user = await User.findById('some-uuid', env.DB); if (user) { console.log(user.data.name); // Access data via .data }
If
softDeletes
is enabled for the model, you can passtrue
as the third argument (includeDeleted
) to also find soft-deleted records. -
YourModel.findOne(column: string, value: string, env: any, includeDeleted?: boolean): Promise<YourModel | null>
Finds the first record that matches a given column-value pair. Returns a model instance or
null
.const adminUser = await User.findOne('email', '[email protected]', env.DB); if (adminUser) { console.log(adminUser.data.id); }
The optional fourth argument
includeDeleted
works the same as infindById
. -
YourModel.findBy(column: string, value: string, env: any, includeDeleted?: boolean): Promise<YourModel[]>
Finds all records that match a given column-value pair. Returns an array of model instances (can be empty).
const activeUsers = await User.findBy('status', 'active', env.DB); activeUsers.forEach(user => { console.log(user.data.email); });
The optional fourth argument
includeDeleted
works the same as infindById
. -
YourModel.all(env: any, includeDeleted?: boolean): Promise<YourModel[]>
Retrieves all records for the model. Returns an array of model instances.
const allUsers = await User.all(env.DB); console.log(`Total users: ${allUsers.length}`); allUsers.forEach(user => { console.log(user.data.name); // Access data via .data });
The optional second argument
includeDeleted
works the same as infindById
.
// Update an entity
const updatedData = {
id: 'existing-uuid', // Required for updates
title: 'Updated Title',
count: 100
};
const updatedEntity = await Entity.update(updatedData, env.DB);
// Access the updated entity's data
console.log(updatedEntity.data.title); // 'Updated Title'
console.log(updatedEntity.data.updated_at); // Current timestamp
// Soft delete an entity using the static Model.delete() method (still supported)
await Entity.delete('entity-uuid', env.DB);
// Entity will no longer be returned in queries by default
const notFound = await Entity.findById('entity-uuid', env.DB);
console.log(notFound); // null
// New: Soft delete an entity using the instance delete() method
const entityToDelete = await Entity.findById('another-entity-uuid', env.DB);
if (entityToDelete) {
await entityToDelete.delete(env.DB);
console.log('Entity soft deleted via instance method.');
}
For more complex operations, you can use raw SQL queries:
// Execute a raw SQL query
const { results } = await Entity.raw(
'SELECT * FROM entities WHERE count > 50 ORDER BY created_at DESC LIMIT 10',
env.DB
);
console.log(results); // Array of raw database results
Model-One is built with TypeScript and provides full type safety. To get the most out of it, define proper interfaces for your models:
// Define your data interface
interface EntityDataI {
id?: string;
title?: string;
count?: number;
is_active?: boolean;
metadata?: Record<string, any>;
published_at?: Date;
created_at?: Date;
updated_at?: Date;
}
// Define your model interface
interface EntityI extends Model {
data: EntityDataI;
}
// Implement your model class
class Entity extends Model implements EntityI {
data: EntityDataI;
constructor(props: EntityDataI = {}) {
super(entitySchema);
this.data = props || {};
}
}
In v0.2.0, all entity properties must be accessed through the data
property:
// v0.1.x (no longer works)
const user = await User.findById(id, env.DB);
console.log(user.name); // Undefined
// v0.2.0 and above
const user = await User.findById(id, env.DB);
console.log(user.data.name); // Works correctly
Models now require proper initialization of the data
property:
// Correct initialization in v0.2.0
class User extends Model implements UserI {
data: UserDataI;
constructor(props: UserDataI = {}) {
super(userSchema);
this.data = props || {}; // Initialize with empty object if props is undefined
}
}
- Create a new database.
Create a local file schema.sql
DROP TABLE IF EXISTS users;
CREATE TABLE users (
id text PRIMARY KEY,
first_name text,
last_name text,
deleted_at datetime,
created_at datetime,
updated_at datetime
);
Creates a new D1 database and provides the binding and UUID that you will put in your wrangler.toml file.
npx wrangler d1 create example-db
Create the tables from schema.sql
npx wrangler d1 execute example-db --file ./schema.sql
- We need to import the Model and Schema from 'model-one' and the type SchemaConfigI. Then create a new Schema, define table name and fields
// ./models/User.ts
import { Model, Schema } from 'model-one'
import type { SchemaConfigI, Column } from 'model-one';
const userSchema: SchemaConfigI = new Schema({
table_name: 'users',
columns: [
{ name: 'id', type: 'string', constraints: [{ type: 'PRIMARY KEY' }] },
{ name: 'first_name', type: 'string' },
{ name: 'last_name', type: 'string' }
],
timestamps: true, // Optional, defaults to true
softDeletes: false // Optional, defaults to false
})
- Then we are going to define the interfaces for our User model.
// ./interfaces/index.ts
export interface UserDataI {
id?: string
first_name?: string
last_name?: string
}
export interface UserI extends Model {
data: UserDataI
}
- Now we are going import the types and extend the User
// ./models/User.ts
import { UserI, UserDataI } from '../interfaces'
export class User extends Model implements UserI {
data: UserDataI
constructor(props: UserDataI) {
super(userSchema, props)
this.data = props
}
}
- Final result of the User model
// ./models/User.ts
import { Model, Schema } from 'model-one'
import type { SchemaConfigI } from 'model-one';
import { UserI, UserDataI } from '../interfaces'
const userSchema: SchemaConfigI = new Schema({
table_name: 'users',
columns: [
{ name: 'id', type: 'string' },
{ name: 'first_name', type: 'string' },
{ name: 'last_name', type: 'string' }
],
})
export class User extends Model implements UserI {
data: UserDataI
constructor(props: UserDataI) {
super(userSchema, props)
this.data = props
}
}
- After creating the User we are going to create the form that handles the validations. And with the help of Joi we are going to define the fields.
// ./forms/UserForm.ts
import { Form } from 'model-one'
import { UserI } from '../interfaces'
import Joi from 'joi'
const schema = Joi.object({
id: Joi.string(),
first_name: Joi.string(),
last_name: Joi.string(),
})
export class UserForm extends Form {
constructor(data: UserI) {
super(schema, data)
}
}
model-one supports the following column types that map to SQLite types:
// JavaScript column types
type ColumnType =
| 'string' // SQLite: TEXT
| 'number' // SQLite: INTEGER or REAL
| 'boolean' // SQLite: INTEGER (0/1)
| 'jsonb' // SQLite: TEXT (JSON stringified)
| 'date'; // SQLite: TEXT (ISO format)
// SQLite native types
type SQLiteType =
| 'TEXT'
| 'INTEGER'
| 'REAL'
| 'NUMERIC'
| 'BLOB'
| 'JSON'
| 'BOOLEAN'
| 'TIMESTAMP'
| 'DATE';
Example usage:
const columns = [
{ name: 'id', type: 'string', sqliteType: 'TEXT' },
{ name: 'name', type: 'string' },
{ name: 'age', type: 'number', sqliteType: 'INTEGER' },
{ name: 'active', type: 'boolean' },
{ name: 'metadata', type: 'jsonb' },
{ name: 'created', type: 'date' }
];
You can add constraints to your columns:
type ConstraintType =
| 'PRIMARY KEY'
| 'NOT NULL'
| 'UNIQUE'
| 'CHECK'
| 'DEFAULT'
| 'FOREIGN KEY';
interface Constraint {
type: ConstraintType;
value?: string | number | boolean;
}
Example:
const columns = [
{
name: 'id',
type: 'string',
constraints: [{ type: 'PRIMARY KEY' }]
},
{
name: 'email',
type: 'string',
constraints: [{ type: 'UNIQUE' }, { type: 'NOT NULL' }]
},
{
name: 'status',
type: 'string',
constraints: [{ type: 'DEFAULT', value: 'active' }]
}
];
You can configure your schema with additional options:
const schema = new Schema({
table_name: 'users',
columns: [...],
uniques: ['email', 'username'], // Composite unique constraints
timestamps: true, // Adds created_at and updated_at columns (default: true)
softDeletes: true // Enables soft delete functionality (default: false)
});
To insert data we need to import the UserForm and we are going start a new User and insert it inside the UserForm, then we can call the method create.
// ./controllers/UserController.ts
import { UserForm } from '../form/UserForm';
import { User } from '../models/User';
const userForm = new UserForm(new User({ first_name, last_name }))
await User.create(userForm, binding)
By importing the User model will have the following methods to query to D1:
// ./controllers/UserController.ts
import { User } from '../models/User';
await User.all(binding)
await User.findById(id, binding)
await User.findOne(column, value, binding)
await User.findBy(column, value, binding)
Include the ID and the fields you want to update inside the data object.
// ./controllers/UserController.ts
import { User } from '../models/User';
// User.update(data, binding)
await User.update({ id, first_name: 'John' }, binding)
Delete a User
// ./controllers/UserController.ts
import { User } from '../models/User';
await User.delete(id, binding)
Execute raw SQL queries with the new raw method:
// ./controllers/UserController.ts
import { User } from '../models/User';
const { success, results } = await User.raw(
`SELECT * FROM users WHERE first_name LIKE '%John%'`,
binding
);
if (success) {
console.log(results);
}
When enabled in your schema configuration, soft delete will set the deleted_at
timestamp instead of removing the record:
const userSchema = new Schema({
table_name: 'users',
columns: [...],
softDeletes: true // Enable soft delete
});
When soft delete is enabled:
delete()
will update thedeleted_at
field instead of removing the recordall()
,findById()
,findOne()
, andfindBy()
will automatically filter out soft-deleted records- You can still access soft-deleted records with raw SQL queries if needed
Extend User methods.
// ./models/User.ts
import { Model, Schema, NotFoundError } from 'model-one'
import type { SchemaConfigI } from 'model-one';
import { UserI, UserDataI } from '../interfaces'
const userSchema: SchemaConfigI = new Schema({
table_name: 'users',
columns: [
{ name: 'id', type: 'string' },
{ name: 'first_name', type: 'string' },
{ name: 'last_name', type: 'string' }
],
})
export class User extends Model implements UserI {
data: UserDataI
constructor(props: UserDataI) {
super(userSchema, props)
this.data = props
}
static async findByFirstName(first_name: string, binding: any) {
// this.findBy(column, value, binding)
return await this.findBy('first_name', first_name, binding)
}
static async rawAll(binding: any) {
const { results, success } = await binding.prepare(`SELECT * FROM ${userSchema.table_name};`).all()
return Boolean(success) ? results : NotFoundError
}
}
- Support JSONB
- Enhanced column types and constraints
- Soft and hard delete
- Basic tests
- Associations: belongs_to, has_one, has_many
- Complex Forms for multiple Models
Julian Clatro
MIT