near-orm
v0.3.0
Published
A simple ORM for IndexedDB
Downloads
219
Maintainers
Readme
Near ORM
A simple ORM for IndexedDB, inspired by Prisma
- 🛠️ Zero dependencies
- 🔑 Fully-typed APIs
- 🔥 Minimalist package (~10KB uncompressed)
- 🚀 Asynchronous API
- 🧩 Schema definition
- 🔄 Query builder
- 🔒 Type-safe migrations
- 📡 Event system
What's new in v0.3.0?
- Seeding your database now triggers the
create
event for each record seeded. - Added the
once
method to the event system, allowing you to listen to an event once. - Added the
off
method to the event system, allowing you to unsubscribe from an event. - Returns an unsubscribe function from the
on
method, allowing you to clean up effectively.
Table of Content
Installation
npm install near-orm
Quick Start
This section provides a quick overview of how to get started with NearORM.
Defining Schema
Like Prisma, you define your database schema before initialising it. In NearORM, you do this using the defineSchema
function.
import { defineSchema, field } from "near-orm";
const schema = defineSchema({
users: {
fields: {
id: field({ type: 'string', primaryKey: true }),
name: field({ type: 'string' }),
email: field({ type: 'string', unique: true }),
createdAt: field({ type: 'date', default: { type: 'now' } }),
updatedAt: field({ type: 'date', default: { type: 'now' } }),
}
}
});
Each record within defineSchema
is similar to a Prisma model/database table.
Initialising ORM
import { ORM } from "near-orm";
const db = await ORM.init({ schema });
init
returns an ORM
instance, which you can use to interact with your database.
CRUD Operations
An ORM
instance has methods for CRUD operations, allowing you to create, update, delete and fetch records from various tables.
The methods can be accessed via the models
property of the ORM
instance.
Create
await db.models.users.create({
email: "[email protected]",
name: "John Doe",
id: "1",
})
Fields with default
defined will be automatically generated if not provided.
Update
await db.models.users.update('1', {
name: "John Doe",
})
The update
method takes in the record's primary key as the first argument, and the new data as the second argument.
Delete
await db.models.users.delete('1')
This deletes the record with the primary key 1
.
Read
To fetch a record, you can use the findById
method.
const user = await db.models.users.findById('1')
This fetches the record with the primary key 1
.
You can also fetch all records from a table using the findAll
method.
const users = await db.models.users.findAll()
This fetches all records from the users
table.
Querying
NearORM also supports querying for records that match a specific criteria via a simple, and intuitive API
const users = await db
.query('user')
.where('name', 'startsWith', 'A')
.orderBy('createdAt', 'desc')
.run()
This fetches all records from the users
table where the name
field starts with A
, and orders them by the createdAt
field in descending order.
Migrations
NearORM also supports migrations, allowing you to create, modify, and delete tables and fields seamlessly.
When creating an ORM, you can choose to handle your migrations automatically (recommended) via the versioning
property
const db = await ORM.init({
schema,
versioning: { type: 'auto' }
})
Or handle it manually:
const db = await ORM.init({
schema,
versioning: { type: 'manual', version: 1 }
})
This would create an IndexedDB store with the version 1, tied to that specific schema. When your schema changes, a migration would need to be manually triggered via the migrate
method
await db.migrate(2);
This would migrate the store to version 2.
You can also pass a migrations
callback to the ORM's init
that gets invoked whenever a new migration occurs, wether automatically or manually.
const db = await ORM.init({
schema,
versioning: { type: 'auto' },
migrations: (oldVersion, newVersion, db) => {
// Do something when a migration occurs
}
})
Transactions
Transactions are basically one of the core tenets of IndexedDB, it means that changes made to a store are isolated. If all goes well, the change is persisted to the store, else, a "rollback" occurs. Meaning that no change is made to the store.
NearORM provides a transaction
API that allows to handle a transation across multiple stores. Ensuring that you can modify multiple tables in one transaction, and rollback if any error occurs (all or nothing).
await db.transaction(async (trx) => {
await trx.users.create({ id: '1', name: 'Abbad', email: '[email protected]' })
await trx.posts.create({ id: '1', title: 'Hello World', content: 'This is my first post', authorId: '1' })
})
This will create a new transaction, adding the users and posts to the database. If any error occurs during the transaction, it will be rolled back, and no data will be persisted to the database.
The beauty of this is that you can perform any CRUD operation within the transaction, and it will ensure that all changes (or none) are persisted to the database.
Read more about IndexedDB transactions here.
Seeding
Seeding is the process of populating your database with data. This is useful for testing and ensuring that your database is populated with the correct data.
await db.seed({
users: [
{
id: '1',
name: 'Abbad',
email: '[email protected]',
createdAt: new Date(),
updatedAt: new Date()
},
{
id: '2',
name: 'John Doe',
email: '[email protected]',
createdAt: new Date(),
updatedAt: new Date()
},
]
})
This would create two new records within your users
table.
Events
Events are a way to listen to changes within your database. This is useful for updating the UI or performing other actions when a record is created, updated, deleted, etc.
db.events.on('create', (storeName, data) => {
console.log(`New record created in ${storeName}:`, data);
});
db.events.on('update', (storeName, data) => {
console.log(`Record updated in ${storeName}:`, data);
});
This would log the new record created in the users
store, and the record updated in the users
store.
It works with any storename, we are using
users
in the example above, but you can use any storename from your schema.
This can be used to create a pub/sub system, wether that includes UI updates or data synchronization, the possibilities are plenty.
Going Raw
NearORM also ships a raw
method that returns the udnerlying IDBDatabase
instance for low-level or non-standard operations.
const idb = db.raw()
Metadata
NearORM also comes with a meta
utility that returns a metadata overview of your database, including size, indexes and column count.
const meta = await db.meta()
This would return an object that resembles:
{
version: 1, // Current version of the database
stores: {
users: {
recordCount: 4, // Number of records (rows) in the store
size: "579.00 B", // Size of the store
indexes: [
"email"
], // List of indexes in the store
keyRange: {
lower: "1", // Lower bound of the key range
upper: "4" // Upper bound of the key range
},
lastUpdated: null // Last updated timestamp
}
}
}
If you find this package useful, please consider sponsoring this project!
API Documentation
Always remember that NearORM is built on top of IndexedDB, which utilises asynchronous APIs for all its operations.
init
Initialises the ORM and returns an ORM
instance.
Signature:
export type InitOptions<S extends Schema> = {
schema: S; // inferred from the schema you pass
dbName?: string;
versioning?: { type: "auto" } | { type: "manual"; version: number };
migrations?: (
oldVersion: number,
newVersion: number,
db: IDBDatabase
) => void;
debug?: boolean;
};
init(options: InitOptions<S>): Promise<ORM<S>>
Example:
import { ORM, defineSchema } from 'near-orm'
const schema = defineSchema({ ... });
const db = await ORM.init({
schema,
dbName: 'my-database',
debug: true,
versioning: { type: 'auto' },
migrations: (oldVersion, newVersion, db) => {
console.log(`Migrating from ${oldVersion} to ${newVersion}`)
}
});
defineSchema
Allows you to create a NearORM-compliant schema.
Signature:
export function defineSchema<T extends Schema>(schema: T): T
Example:
import { defineSchema, field } from "near-orm";
const schema = defineSchema({ /* ... */ });
field
Creates a column definition for your schema.
Signature:
type FieldType = "string" | "number" | "boolean" | "date";
type FieldDefinition<T extends FieldType> = {
type: T;
primaryKey?: boolean;
unique?: boolean;
default?: DefaultValueForType<T>;
};
function field(def: FieldDefinition<FieldType>): FieldDefinitionWithMeta<FieldType>
Example:
import { defineSchema, field } from "near-orm";
const schema = defineSchema({
users: {
fields: {
id: field({ type: 'string', primaryKey: true }),
name: field({ type: 'string' }),
email: field({ type: 'string', unique: true }),
createdAt: field({ type: 'date', default: { type: 'now' } }),
}
}
});
Depending on the type
of your field, default
supports:
"autoincrement"
: Increments the value of the field by one. The algorithm is handled by IndexedDB key generator."now"
: Sets the field to the current date and time."function"
: Allows you to pass a function that returns the default value (must be of the same type as your field)."static"
: Allows you to pass a static value - like an enum (must be of the same type as your field).
Some field types, like number
, support "autoincrement", whilst the rest don't.
models
Model is basically a Proxy
object that handles all the magic for CRUD operations.
create
Creates a new record in your table.
Example:
await db.models['name-of-your-table'].create({ /* ... */ })
It automatically infers the table names as well as the columns from your schema.
update
Updates one or more columns in a record.
Example:
await db.models['name-of-your-table'].update('id', { /* ... */ })
delete
Deletes a record from your table.
Example:
await db.models['name-of-your-table'].delete('id')
findById
Finds a record by its primary key.
Example:
const user = await db.models['name-of-your-table'].findById('id')
findAll
Gets all records in your table.
Example:
const users = await db.models['name-of-your-table'].findAll()
query
Returns a QueryBuilder
that enables you to filter, sort and paginate records within a table.
Signature:
class ORM<S extends Schema> {
// ...
query<K extends keyof S>(storeName: K): QueryBuilder<S[K]["fields"]>
}
Example:
const users = await db
.query('users')
.where('name', 'startsWith', 'A')
.orderBy('createdAt', 'desc')
.run()
QueryBuilder
A class that ships with methods for querying, and a run
to execute your query
where
Signature:
type WhereOperator = "equals" | "startsWith" | "endsWith";
where(field: string, operator: WhereOperator, value: any): QueryBuilder
Example:
const filtered = await db
.query('users')
.where('name', 'startsWith', 'Z')
.run();
orderBy
Allows you to sort your query results according to a particular column in ascending or descending order
Signature:
orderBy(field: string, order: "asc" | "desc"): QueryBuilder
Example:
const sorted = await db
.query('users')
.orderBy('name', 'desc')
.run();
offset
Allows you to create a pagination utility, by allowing you to skip a certain number of fields
Signature:
offset(count: number): QueryBuilder
Example:
const threeOffset = await db
.query('users')
.offset(5) /* skip the first 5 records */
.run();
limit
Goes hand-in-hand with the offset
method, allowing you to limit the number of records returned.
Signature:
limit(count: number): QueryBuilder
Example:
const pageTwo = await db
.query('users')
.offset(10) /* skip the first 10 records */
.limit(10) /* limit to 10 records */
.run();
[!NOTE] Re-using a method will override the previous one. Except for
where
, which would simply combine with the previouswhere
clause to provide a more complex filtering mechanism.For example, calling
.limit()
twice will override the previouslimit
clause.const users = await db .query('users') .limit(10) .limit(20) // This will override the previous limit of 10 .run();
run
Executes the query and returns the results.
Signature:
run(): Promise<T[]>
Example:
const users = await db
.query('users')
.where('name', 'startsWith', 'A')
.orderBy('createdAt', 'desc')
.offset(10)
.limit(10)
.run();
Got an idea for a new query method? Feel free to open an issue
meta
Returns a metadata overview of your database, including size, indexes and column count.
Signature:
meta(): Promise<Record<string, any>>
Example:
const meta = await db.meta()
transaction
A utility method that allows you to perform a transaction across multiple stores.
Example:
await db.transaction(async (trx) => {
await trx.users.create({ id: '1', name: 'Abbad', email: '[email protected]' })
await trx.posts.create({ id: '1', title: 'Hello World', content: 'This is my first post', authorId: '1' })
})
seed
A method that allows you to seed your database with data.
Example:
await db.seed({
users: [
{ id: '1', name: 'Abbad', email: '[email protected]' },
{ id: '2', name: 'John Doe', email: '[email protected]' },
],
posts: [
{ id: '1', title: 'Hello World', content: 'This is my first post', authorId: '1' },
{ id: '2', title: 'Hello World', content: 'This is my second post', authorId: '2' },
]
})
migrate
A method that allows you to manually migrate your database to a new version. Re-applying the new schema to the database, and updating the version number.
[!CAUTION] This throws an error if your versioning and migration is already handled automatically.
Signature:
migrate(version: number): Promise<void>
Example:
await db.migrate(2)
events
Events are a way to listen to changes within your database. This is useful for updating the UI or performing other actions when a record is created, updated, deleted, etc.
on
Listens to events within your database. It returns a function that allows you to unsubscribe from the event.
Signature:
on(
eventName: "create" | "update" | "delete",
callback: (storeName: string, record: any) => void
): () => void
Example:
const unsubscribe = db.events.on('create', (storeName, data) => {
console.log(`New record created in ${storeName}:`, data);
});
// ...
unsubscribe();
trigger
[!WARNING] This is a low-level method that requires you to manually track events within your codebase. Do not use this method unless you know what you are doing!
Triggers an event within your database. This is useful for creating your own event system. Can be combined with raw
to build your own ORM.
Signature:
trigger(
eventName: "create" | "update" | "delete",
storeName: string,
record: any
): void
Example:
db.events.trigger('create', 'users', { id: '1', name: 'Abbad', email: '[email protected]' })
off
Unsubscribes from an event.
Signature:
off(eventName: "create" | "update" | "delete", callback: EventCallback<S>): void
Example:
const callback = (storeName, data) => {
console.log(`New record created in ${storeName}:`, data);
}
db.events.on('create', callback);
// ...
db.events.off('create', callback);
once
Listens to an event once.
Signature:
once(eventName: "create" | "update" | "delete", callback: EventCallback<S>): void
Example:
// Triggers the callback once, and then unsubscribes from the event
// immediately after
db.events.once('create', (storeName, data) => {
console.log(`New record created in ${storeName}:`, data);
});
raw
Returns the underlying IDBDatabase
instance for low-level or non-standard operations.
[!WARNING] This returns the raw IndexedDB API, and does not go through the ORM's type system. This means that you can bypass all the ORM's type safety and integrity checks. Here be dragons!
Signature:
raw(): IDBDatabase
Example:
const idb = db.raw()
License
MIT