The ultimate Object Relational Mapper for Node.js, Bun and Deno, offering seamless integration with a variety of popular databases. Orange ORM supports both TypeScript and JavaScript, including both CommonJS and ECMAScript.

Key Features
- Rich Querying Model: Orange provides a powerful and intuitive querying model, making it easy to retrieve, filter, and manipulate data from your databases.
- Active Record: With a concise and expressive syntax, Orange enables you to interact with your database using the Active Record Pattern.
- No Code Generation Required: Enjoy full IntelliSense, even in table mappings, without the need for cumbersome code generation.
- TypeScript and JavaScript Support: Orange fully supports both TypeScript and JavaScript, allowing you to leverage the benefits of static typing and modern ECMAScript features.
- Works in the Browser: You can securely use Orange in the browser by utilizing the Express.js or Hono plugin, which serves to safeguard sensitive database credentials from exposure at the client level and protect against SQL injection. This method mirrors a traditional REST API, augmented with advanced TypeScript tooling for enhanced functionality.
Supported Databases and Runtimes
|
Node |
Deno |
Bun |
Cloudflare |
Web |
| Postgres |
✅ |
✅ |
✅ |
✅ |
|
| PGlite |
✅ |
✅ |
✅ |
✅ |
✅ |
| MS SQL |
✅ |
|
✅ |
|
|
| MySQL |
✅ |
✅ |
✅ |
|
|
| MariaDB |
✅ |
✅ |
✅ |
|
|
| Oracle |
✅ |
✅ |
✅ |
|
|
| SAP ASE |
✅ |
|
|
|
|
| SQLite |
✅ |
✅ |
✅ |
|
|
| Cloudflare D1 |
|
|
|
✅ |
|
Orange ORM is free and open source, maintained in my spare time.
If Orange saves you or your company time, sponsorship helps fund ongoing development, bug fixes, documentation, and long-term maintenance.
👉 Sponsor Orange ORM on GitHub
MCP (Model Context Protocol)
Orange ORM is available as an MCP resource on Context7. Use it with AI-powered tools like GitHub Copilot, Cursor, or Claude to get up-to-date documentation and code examples directly in your IDE.
👉 https://context7.com/alfateam/orange-orm
Installation
npm install orange-orm
Example
Watch the tutorial video on YouTube

📄 map.ts
import orange from 'orange-orm';
const map = orange.map(x => ({
customer: x.table('customer').map(({ column }) => ({
id: column('id').numeric().primary().notNullExceptInsert(),
name: column('name').string(),
balance: column('balance').numeric(),
isActive: column('isActive').boolean(),
})),
order: x.table('_order').map(({ column }) => ({
id: column('id').numeric().primary().notNullExceptInsert(),
orderDate: column('orderDate').date().notNull(),
customerId: column('customerId').numeric().notNullExceptInsert(),
})),
orderLine: x.table('orderLine').map(({ column }) => ({
id: column('id').numeric().primary(),
orderId: column('orderId').numeric(),
product: column('product').string(),
amount: column('amount').numeric(),
})),
package: x.table('package').map(({ column }) => ({
id: column('packageId').numeric().primary().notNullExceptInsert(),
lineId: column('lineId').numeric().notNullExceptInsert(),
sscc: column('sscc').string() //the barcode
})),
deliveryAddress: x.table('deliveryAddress').map(({ column }) => ({
id: column('id').numeric().primary(),
orderId: column('orderId').numeric(),
name: column('name').string(),
street: column('street').string(),
postalCode: column('postalCode').string(),
postalPlace: column('postalPlace').string(),
countryCode: column('countryCode').string().enum(['NO', 'SE', 'DK', 'FI', 'IS', 'DE', 'FR', 'NL', 'ES', 'IT']),
}))
})).map(x => ({
orderLine: x.orderLine.map(({ hasMany }) => ({
packages: hasMany(x.package).by('lineId')
})),
order: x.order.map(v => ({
customer: v.references(x.customer).by('customerId'),
lines: v.hasMany(x.orderLine).by('orderId'),
deliveryAddress: v.hasOne(x.deliveryAddress).by('orderId'),
}))
}));
export default map;
📄 update.ts
import map from './map';
const db = map.sqlite('demo.db');
updateRow();
async function updateRow() {
const order = await db.order.getById(2, {
lines: true
});
order.lines.push({
product: 'broomstick',
amount: 300
});
await order.saveChanges();
}
📄 filter.ts
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const orders = await db.order.getMany({
where: x => x.lines.any(line => line.product.contains('broomstick'))
.and(x.customer.name.startsWith('Harry')),
lines: {
packages: true
},
deliveryAddress: true,
customer: true
});
}
API
Mapping tables
To define a mapping, you employ the map() method, linking your tables and columns to corresponding object properties. You provide a callback function that engages with a parameter representing a database table.
Each column within your database table is designated by using the column() method, in which you specify its name. This action generates a reference to a column object that enables you to articulate further column properties like its data type or if it serves as a primary key.
Relationships between tables can also be outlined. By using methods like hasOne, hasMany, and references, you can establish connections that reflect the relationships in your data schema. In the example below, an ‘order’ is linked to a ‘customer’ reference, a ‘deliveryAddress’, and multiple ‘lines’. The hasMany and hasOne relations represents ownership - the tables ‘deliveryAddress’ and ‘orderLine’ are owned by the ‘order’ table, and therefore, they contain the ‘orderId’ column referring to their parent table, which is ‘order’. The similar relationship exists between orderLine and package - hence the packages are owned by the orderLine. Conversely, the customer table is independent and can exist without any knowledge of the ‘order’ table. Therefore we say that the order table references the customer table - necessitating the existence of a ‘customerId’ column in the ‘order’ table.
📄 map.ts
import orange from 'orange-orm';
const map = orange.map(x => ({
customer: x.table('customer').map(({ column }) => ({
id: column('id').numeric().primary().notNullExceptInsert(),
name: column('name').string(),
balance: column('balance').numeric(),
isActive: column('isActive').boolean(),
})),
order: x.table('_order').map(({ column }) => ({
id: column('id').numeric().primary().notNullExceptInsert(),
orderDate: column('orderDate').date().notNull(),
customerId: column('customerId').numeric().notNullExceptInsert(),
})),
orderLine: x.table('orderLine').map(({ column }) => ({
id: column('id').numeric().primary(),
orderId: column('orderId').numeric(),
product: column('product').string(),
})),
package: x.table('package').map(({ column }) => ({
id: column('packageId').numeric().primary().notNullExceptInsert(),
lineId: column('lineId').numeric().notNullExceptInsert(),
sscc: column('sscc').string() //the barcode
})),
deliveryAddress: x.table('deliveryAddress').map(({ column }) => ({
id: column('id').numeric().primary(),
orderId: column('orderId').numeric(),
name: column('name').string(),
street: column('street').string(),
postalCode: column('postalCode').string(),
postalPlace: column('postalPlace').string(),
countryCode: column('countryCode').string().enum(['NO', 'SE', 'DK', 'FI', 'IS', 'DE', 'FR', 'NL', 'ES', 'IT']),
}))
})).map(x => ({
orderLine: x.orderLine.map(({ hasMany }) => ({
packages: hasMany(x.package).by('lineId')
})),
order: x.order.map(({ hasOne, hasMany, references }) => ({
customer: references(x.customer).by('customerId'),
deliveryAddress: hasOne(x.deliveryAddress).by('orderId'),
lines: hasMany(x.orderLine).by('orderId')
}))
}));
export default map;
The init.ts script resets our SQLite database. It’s worth noting that SQLite databases are represented as single files, which makes them wonderfully straightforward to manage.
At the start of the script, we import our database mapping from the map.ts file. This gives us access to the db object, which we’ll use to interact with our SQLite database.
Then, we define a SQL string. This string outlines the structure of our SQLite database. It first specifies to drop existing tables named ‘deliveryAddress’, ‘package’, ‘orderLine’, ‘_order’, and ‘customer’ if they exist. This ensures we have a clean slate. Then, it dictates how to create these tables anew with the necessary columns and constraints.
Because of a peculiarity in SQLite, which only allows one statement execution at a time, we split this SQL string into separate statements. We do this using the split() method, which breaks up the string at every semicolon.
📄 init.ts
import map from './map';
const db = map.sqlite('demo.db');
const sql = `DROP TABLE IF EXISTS deliveryAddress;
DROP TABLE IF EXISTS package;
DROP TABLE IF EXISTS orderLine;
DROP TABLE IF EXISTS _order;
DROP TABLE IF EXISTS customer;
CREATE TABLE customer (
id INTEGER PRIMARY KEY,
name TEXT,
balance NUMERIC,
isActive INTEGER
);
CREATE TABLE _order (
id INTEGER PRIMARY KEY,
orderDate TEXT,
customerId INTEGER REFERENCES customer
);
CREATE TABLE orderLine (
id INTEGER PRIMARY KEY,
orderId INTEGER REFERENCES _order,
product TEXT,
amount NUMERIC(10,2)
);
CREATE TABLE package (
packageId INTEGER PRIMARY KEY,
lineId INTEGER REFERENCES orderLine,
sscc TEXT
);
CREATE TABLE deliveryAddress (
id INTEGER PRIMARY KEY,
orderId INTEGER REFERENCES _order,
name TEXT,
street TEXT,
postalCode TEXT,
postalPlace TEXT,
countryCode TEXT
)
`;
async function init() {
const statements = sql.split(';');
for (let i = 0; i < statements.length; i++) {
await db.query(statements[i]);
}
}
export default init;
In SQLite, columns with the INTEGER PRIMARY KEY attribute are designed to autoincrement by default. This means that each time a new record is inserted into the table, SQLite automatically produces a numeric key for the id column that is one greater than the largest existing key. This mechanism is particularly handy when you want to create unique identifiers for your table rows without manually entering each id.
Connecting
SQLite
When running Node.js 21 and earlier, you need to install the sqlite3 dependency.
When running Node.js 22 and later, Bun, or Deno, you don’t need it as it is built-in.
npm install sqlite3
import map from './map';
const db = map.sqlite('demo.db');
await db.close();
With connection pool
npm install sqlite3
import map from './map';
const db = map.sqlite('demo.db', { size: 10 });
await pool.close();
Why close ?
In serverless environments (e.g. AWS Lambda, Vercel, Cloudflare Workers) execution contexts are frequently frozen and resumed. Explicitly closing the client or pool ensures that file handles are released promptly and prevents “database locked” errors between invocations.
SQLite user-defined functions
You can register custom SQLite functions using db.function(name, fn).
For full behavior, runtime caveats, and examples, see SQLite user-defined functions.
From the browser
You can securely use Orange from the browser by utilizing the Express plugin, which serves to safeguard sensitive database credentials from exposure at the client level. This technique bypasses the need to transmit raw SQL queries directly from the client to the server. Instead, it logs method calls initiated by the client, which are later replayed and authenticated on the server. This not only reinforces security by preventing the disclosure of raw SQL queries on the client side but also facilitates a smoother operation. Essentially, this method mirrors a traditional REST API, augmented with advanced TypeScript tooling for enhanced functionality. You can read more about it in the section called In the browser
📄 server.ts
import map from './map';
import { json } from 'body-parser';
import express from 'express';
import cors from 'cors';
const db = map.sqlite('demo.db');
express().disable('x-powered-by')
.use(json({ limit: '100mb' }))
.use(cors())
//for demonstrational purposes, authentication middleware is not shown here.
.use('/orange', db.express())
.listen(3000, () => console.log('Example app listening on port 3000!'));
📄 browser.ts
import map from './map';
const db = map.http('http://localhost:3000/orange');
MySQL
$ npm install mysql2
import map from './map';
const db = map.mysql('mysql://test:test@mysql/test');
MariaDB
$ npm install mysql2
import map from './map';
const db = map.mariadb('mariadb://test:test@mariadb/test');
MS SQL
npm install tedious
import map from './map';
const db = map.mssql({
server: 'mssql',
options: {
encrypt: false,
database: 'test'
},
authentication: {
type: 'default',
options: {
userName: 'sa',
password: 'P@assword123',
}
}
});
PostgreSQL
With Bun, you don’t need to install the pg package as PostgreSQL support is built-in.
npm install pg
import map from './map';
const db = map.postgres('postgres://postgres:postgres@postgres/postgres');
With schema
import map from './map';
const db = map.postgres('postgres://postgres:postgres@postgres/postgres?search_path=custom');
PGlite
npm install @electric-sql/pglite
In this example we use the in-memory Postgres.
Read more about PGLite connection configs.
import map from './map';
const db = map.pglite( );
Cloudflare D1
📄 wrangler.toml
name = "d1-tutorial"
main = "src/index.ts"
compatibility_date = "2025-02-04"
# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database.
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases
[[d1_databases]]
binding = "DB"
database_name = "<your-name-for-the-database>"
database_id = "<your-guid-for-the-database>"
📄 src/index.ts
import map from './map';
export interface Env {
// Must match the binding name in wrangler.toml
DB: D1Database;
}
export default {
async fetch(request, env): Promise<Response> {
const db = map.d1(env.DB);
const customers = await db.customer.getMany();
return Response.json(customers);
},
} satisfies ExportedHandler<Env>;
Oracle
npm install oracledb
import map from './map';
const db = map.oracle({
user: 'sys',
password: 'P@assword123',
connectString: 'oracle/XE',
privilege: 2
});
SAP Adaptive Server
Even though msnodesqlv8 was developed for MS SQL, it also works for SAP ASE as it is ODBC compliant.
npm install msnodesqlv8
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import map from './map';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const db = map.sap(`Driver=${__dirname}/libsybdrvodb.so;SERVER=sapase;Port=5000;UID=sa;PWD=sybase;DATABASE=test`);
Inserting rows
In the code below, we initially import the table-mapping feature "map.ts" and the setup script "init.ts", both of which were defined in the preceding step. The setup script executes a raw query that creates the necessary tables. Subsequently, we insert two customers, named "George" and "Harry", into the customer table, and this is achieved through calling "db.customer.insert".
Next, we insert an array of two orders in the order table. Each order contains an orderDate, customer information, deliveryAddress, and lines for the order items. We use the customer constants “george” and “harry” from previous inserts. Observe that we don’t pass in any primary keys. This is because all tables here have autoincremental keys. The second argument to “db.order.insert” specifies a fetching strategy. This fetching strategy plays a critical role in determining the depth of the data retrieved from the database after insertion. The fetching strategy specifies which associated data should be retrieved and included in the resulting orders object. In this case, the fetching strategy instructs the database to retrieve the customer, deliveryAddress, and lines for each order.
Without a fetching strategy, “db.order.insert” would only return the root level of each order. In that case you would only get the id, orderDate, and customerId for each order.
import map from './map';
const db = map.sqlite('demo.db');
import init from './init';
insertRows();
async function insertRows() {
await init();
const george = await db.customer.insert({
name: 'George',
balance: 177,
isActive: true
});
const harry = await db.customer.insert({
name: 'Harry',
balance: 200,
isActive: true
});
const orders = await db.order.insert([
{
orderDate: new Date(2022, 0, 11, 9, 24, 47),
customer: george,
deliveryAddress: {
name: 'George',
street: 'Node street 1',
postalCode: '7059',
postalPlace: 'Jakobsli',
countryCode: 'NO'
},
lines: [
{ product: 'Bicycle', amount: 250 },
{ product: 'Small guitar', amount: 150 }
]
},
{
customer: harry,
orderDate: new Date(2021, 0, 11, 12, 22, 45),
deliveryAddress: {
name: 'Harry Potter',
street: '4 Privet Drive, Little Whinging',
postalCode: 'GU4',
postalPlace: 'Surrey',
countryCode: 'UK'
},
lines: [
{ product: 'Magic wand', amount: 300 }
]
}
], {customer: true, deliveryAddress: true, lines: true});
}
Conflict resolution
By default, the strategy for inserting rows is set to an optimistic approach. In this case, if a row is being inserted with an already existing primary key, the database raises an exception.
Currently, there are three concurrency strategies:
optimistic Raises an exception if another row was already inserted on that primary key.
overwrite Overwrites the property, regardless of changes by others.
skipOnConflict Silently avoids updating the property if another user has modified it in the interim.
The concurrency option can be set either for the whole table or individually for each column. In the example below, we’ve set the concurrency strategy on vendor table to overwrite except for the column balance which uses the skipOnConflict strategy. In this particular case, a row with id: 1 already exists, the name and isActive fields will be overwritten, but the balance will remain the same as in the original record, demonstrating the effectiveness of combining multiple concurrency strategies.
import map from './map';
const db = map.sqlite('demo.db');
insertRows();
async function insertRows() {
db2 = db({
vendor: {
balance: {
concurrency: 'skipOnConflict'
},
concurrency: 'overwrite'
}
});
await db2.vendor.insert({
id: 1,
name: 'John',
balance: 100,
isActive: true
});
const george = await db2.vendor.insert({
id: 1,
name: 'George',
balance: 177,
isActive: false
});
console.dir(george, {depth: Infinity});
}
Fetching rows
Orange has a rich querying model. As you navigate through, you'll learn about the various methods available to retrieve data from your tables, whether you want to fetch all rows, many rows with specific criteria, or a single row based on a primary key.
The fetching strategy in Orange is optional, and its use is influenced by your specific needs. You can define the fetching strategy either on the table level or the column level. This granularity gives you the freedom to decide how much related data you want to pull along with your primary request.
All rows
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const orders = await db.order.getMany({
customer: true,
deliveryAddress: true,
lines: {
packages: true
}
});
}
Limit, offset and order by
This script demonstrates how to fetch orders with customer, lines, packages and deliveryAddress, limiting the results to 10, skipping the first row, and sorting the data based on the orderDate in descending order followed by id. The lines are sorted by product.
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const orders = await db.order.getMany({
offset: 1,
orderBy: ['orderDate desc', 'id'],
limit: 10,
customer: true,
deliveryAddress: true,
lines: {
packages: true,
orderBy: 'product',
limit: 2
},
});
}
limit and offset inside a mapped many relation are applied independently for each parent. In this example, each order receives at most two lines. The relation query is batched across up to 200 parents at a time, or fewer when required by the database’s parameter limit, rather than issuing one query per parent. Primary and foreign keys needed for relation attachment and change tracking are selected internally even when they are not part of the TypeScript result selection.
With aggregated results
You can count records and aggregate numerical columns.
The following operators are supported:
You can also elevate associated data to a parent level for easier access. In the example below, balance of the customer is elevated to the root level.
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const orders = await db.order.getMany({
numberOfLines: x => x.count(x => x.lines.id),
totalAmount: x => x.sum(x => lines.amount),
balance: x => x.customer.balance
});
}
Many rows filtered
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const orders = await db.order.getMany({
where: x => x.lines.any(line => line.product.contains('i'))
.and(x.customer.balance.greaterThan(180)),
customer: true,
deliveryAddress: true,
lines: true
});
}
You can also build the where filter separately and pass it in via the where clause. This keeps the filter independent of the fetching strategy and easier to reuse.
async function getRows() {
const filter = db.order.lines.any(line => line.product.contains('i'))
.and(db.order.customer.balance.greaterThan(180));
const orders = await db.order.getMany({
where: filter,
customer: true,
deliveryAddress: true,
lines: true
});
}
Single row filtered
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const order = await db.order.getOne({
where: x => x.customer(customer => customer.isActive.eq(true)
.and(customer.startsWith('Harr'))),
customer: true,
deliveryAddress: true,
lines: true
});
}
You can also build the where filter independently and reuse it.
With getOne, you can combine the positional where filter with the where option to compose filters.
async function getRows() {
const filter = db.order.customer(customer => customer.isActive.eq(true)
.and(customer.startsWith('Harr')));
const order = await db.order.getOne({
where: filter,
customer: true,
deliveryAddress: true,
lines: true
});
}
Single row by primary key
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const order = await db.order.getById(1, {
customer: true,
deliveryAddress: true,
lines: true
});
}
Many rows by primary key
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const orders = await db.order.getMany({
where: () => [
{id: 1},
{id: 2}
],
customer: true,
deliveryAddress: true,
lines: true
});
}
Updating rows
To update rows, modify the property values and invoke the method saveChanges(). The function updates only the modified columns, not the entire row. Rows in child relations can also be updated as long as the parent order owns the child tables. In our illustration, the order table owns both the deliveryAddress and the lines tables because they're part of a hasOne/hasMany relationship. Contrastingly, the customer is part of a reference relationship and thus can't be updated here. But you can detach the reference to the customer by assigning it to null or undefined. (Setting order.customerId to null or undefined achieves the same result.)
Updating a single row
import map from './map';
const db = map.sqlite('demo.db');
update();
async function update() {
const order = await db.order.getById(1, {
customer: true,
deliveryAddress: true,
lines: true
});
order.orderDate = new Date();
order.deliveryAddress = null;
order.lines.push({product: 'Cloak of invisibility', amount: 600});
await order.saveChanges();
}
Updating many rows
import map from './map';
const db = map.sqlite('demo.db');
update();
async function update() {
let orders = await db.order.getMany({
orderBy: 'id',
lines: true,
deliveryAddress: true,
customer: true
});
orders[0].orderDate = new Date();
orders[0].deliveryAddress.street = 'Node street 2';
orders[0].lines[1].product = 'Big guitar';
orders[1].orderDate = '2023-07-14T12:00:00';
orders[1].deliveryAddress = null;
orders[1].customer = null;
orders[1].lines.push({product: 'Cloak of invisibility', amount: 600});
await orders.saveChanges();
}
Selective updates
The update method is ideal for updating specific columns and relationships across one or multiple rows. You must provide a where filter to specify which rows to target. If you include a fetching strategy, the affected rows and their related data will be returned; otherwise, no data is returned.
import map from './map';
const db = map.sqlite('demo.db');
update();
async function update() {
const propsToBeModified = {
orderDate: new Date(),
customerId: 2,
lines: [
{ id: 1, product: 'Bicycle', amount: 250 },
{ id: 2, product: 'Small guitar', amount: 150 },
{ product: 'Piano', amount: 800 }
]
};
const strategy = {customer: true, deliveryAddress: true, lines: true};
const orders = await db.order.update(propsToBeModified, { where: x => x.id.eq(1) }, strategy);
}
Row locks with forUpdate and skipLocked
Use forUpdate: true in a fetching strategy to lock selected rows for update until the current transaction completes. Add skipLocked: true when concurrent workers should skip rows that are already locked instead of waiting for them. This is useful for queue-like workloads where several workers pick the next available rows.
forUpdate and skipLocked should be used inside a transaction. They are supported by PostgreSQL/PGlite, MySQL, MariaDB, Oracle, and MS SQL. SQLite and SAP ASE throw an error because row locking with SELECT FOR UPDATE is not supported there.
import map from './map';
const db = map.postgres('postgres://postgres:postgres@postgres/postgres');
async function claimNextOrders() {
return await db.transaction(async tx => {
const orders = await tx.order.getMany({
where: x => x.orderDate.lessThan(new Date()),
orderBy: 'id',
limit: 10,
forUpdate: true,
skipLocked: true,
lines: {
forUpdate: true
}
});
for (const order of orders) {
order.orderDate = new Date();
}
await orders.saveChanges();
return orders;
});
}
The same lock strategy can be passed to write helpers that return affected rows:
const updated = await db.transaction(async tx => {
return await tx.customer.update(
{ name: 'Updated' },
{ where: x => x.id.eq(customerId) },
{ forUpdate: true, skipLocked: true }
);
});
Replacing a row from JSON
The replace method is suitable when a complete overwrite is required from a JSON object - typically in a REST API. However, it’s important to consider that this method replaces the entire row and it’s children, which might not always be desirable in a multi-user environment.
import map from './map';
const db = map.sqlite('demo.db');
replace();
async function replace() {
const modified = {
id: 1,
orderDate: '2023-07-14T12:00:00',
customer: {
id: 2
},
deliveryAddress: {
name: 'Roger',
street: 'Node street 1',
postalCode: '7059',
postalPlace: 'Jakobsli',
countryCode: 'NO'
},
lines: [
{ id: 1, product: 'Bicycle', amount: 250 },
{ id: 2, product: 'Small guitar', amount: 150 },
{ product: 'Piano', amount: 800 }
]
};
const order = await db.order.replace(modified, {customer: true, deliveryAddress: true, lines: true});
}
Partially updating from JSON
The updateChanges method applies a partial update based on difference between original and modified row. It is often preferable because it minimizes the risk of unintentionally overwriting data that may have been altered by other users in the meantime. To do so, you need to pass in the original row object before modification as well.
import map from './map';
const db = map.sqlite('demo.db');
update();
async function update() {
const original = {
id: 1,
orderDate: '2023-07-14T12:00:00',
customer: {
id: 2
},
deliveryAddress: {
id: 1,
name: 'George',
street: 'Node street 1',
postalCode: '7059',
postalPlace: 'Jakobsli',
countryCode: 'NO'
},
lines: [
{ id: 1, product: 'Bicycle', amount: 250 },
{ id: 2, product: 'Small guitar', amount: 150 }
]
};
const modified = JSON.parse(JSON.stringify(original));
modified.deliveryAddress.name = 'Roger';
modified.lines.push({ product: 'Piano', amount: 800 });
const order = await db.order.updateChanges(modified, original, { customer: true, deliveryAddress: true, lines: true });
}
Conflict resolution
Rows get updated using an optimistic concurrency approach by default. This means if a property being edited was meanwhile altered, an exception is raised, indicating the row was modified by a different user. You can change the concurrency strategy either at the table or column level.
Currently, there are three concurrency strategies:
optimistic Raises an exception if another user changes the property during an update.
overwrite Overwrites the property, regardless of changes by others.
skipOnConflict Silently avoids updating the property if another user has modified it in the interim.
In the example below, we’ve set the concurrency strategy for orderDate to ‘overwrite’. This implies that if other users modify orderDate while you’re making changes, their updates will be overwritten.
import map from './map';
const db = map.sqlite('demo.db');
update();
async function update() {
const order = await db.order.getById(1, {
customer: true,
deliveryAddress: true,
lines: true
});
order.orderDate = new Date();
order.deliveryAddress = null;
order.lines.push({product: 'Cloak of invisibility', amount: 600});
await order.saveChanges( {
orderDate: {
concurrency: 'overwrite'
}});
}
Upserting rows
It is possible to perform 'upserts' by taking advantage of the 'overwrite' strategy.
Currently, there are three concurrency strategies:
optimistic Raises an exception if another row was already inserted on that primary key.
overwrite Overwrites the property, regardless of changes by others.
skipOnConflict Silently avoids updating the property if another user has modified it in the interim.
The concurrency option can be set either for the whole table or individually for each column. In the example below, we’ve set the concurrency strategy on vendor table to overwrite except for the column balance which uses the skipOnConflict strategy. In this particular case, a row with id: 1 already exists, the name and isActive fields will be overwritten, but the balance will remain the same as in the original record, demonstrating the effectiveness of combining multiple concurrency strategies.
import map from './map';
const db = map.sqlite('demo.db');
insertRows();
async function insertRows() {
db2 = db({
vendor: {
balance: {
concurrency: 'skipOnConflict'
},
concurrency: 'overwrite'
}
});
await db2.vendor.insert({
id: 1,
name: 'John',
balance: 100,
isActive: true
});
const george = await db2.vendor.insert({
id: 1,
name: 'George',
balance: 177,
isActive: false
});
console.dir(george, {depth: Infinity});
}
Deleting rows
Rows in owner tables cascade deletes to their child tables. In essence, if a table has ownership over other tables through hasOne and hasMany relationships, removing a record from the parent table also removes its corresponding records in its child tables. This approach safeguards against leaving orphaned records and upholds data integrity. On the contrary, tables that are merely referenced, through reference relationships , remain unaffected upon deletions. For a deeper dive into these relationships and behaviors, refer to the section on Mapping tables.
Deleting a single row
import map from './map';
const db = map.sqlite('demo.db');
deleteRow();
async function deleteRow() {
const order = await db.order.getById(1);
await order.delete();
}
Deleting a row in an array
A common workflow involves retrieving multiple rows, followed by the need to delete a specific row from an array. This operation is straightforward to do with Orange, which allow for the updating, inserting, and deleting of multiple rows in a single transaction. To modify the array, simply add, update, or remove elements, and then invoke the saveChanges() method on the array to persist the changes.
import map from './map';
const db = map.sqlite('demo.db');
updateInsertDelete();
async function updateInsertDelete() {
const orders = await db.order.getMany({
customer: true,
deliveryAddress: true,
lines: true
});
orders[0].lines.push({
product: 'secret weapon',
amount: 355
});
orders.splice(1, 1);
orders.push({
orderDate: new Date(2022, 0, 11, 9, 24, 47),
customer: {
id: 1
},
deliveryAddress: {
name: 'George',
street: 'Node street 1',
postalCode: '7059',
postalPlace: 'Jakobsli',
countryCode: 'NO'
},
lines: [
{ product: 'Magic tent', amount: 349 }
]
});
await orders.saveChanges();
}
Deleting many rows
import map from './map';
const db = map.sqlite('demo.db');
deleteRows();
async function deleteRows() {
let orders = await db.order.getMany({
where: x => x.customer.name.eq('George')
});
await orders.delete();
}
Deleting with concurrency
Concurrent operations can lead to conflicts. When you still want to proceed with the deletion regardless of potential interim changes, the ‘overwrite’ concurrency strategy can be used. This example demonstrates deleting rows even if the “delivery address” has been modified in the meantime. You can read more about concurrency strategies in Updating rows.
import map from './map';
const db = map.sqlite('demo.db');
deleteRows();
async function deleteRows() {
let orders = await db.order.getMany({
where: x => x.deliveryAddress.name.eq('George'),
customer: true,
deliveryAddress: true,
lines: true
});
await orders.delete({
deliveryAddress: {
concurrency: 'overwrite'
}
});
}
Batch delete
When removing a large number of records based on a certain condition, batch deletion can be efficient.
However, it’s worth noting that batch deletes don’t follow the cascade delete behavior by default. To achieve cascading in batch deletes, you must explicitly call the deleteCascade method.
import map from './map';
const db = map.sqlite('demo.db');
deleteRows();
async function deleteRows() {
const filter = db.order.deliveryAddress.name.eq('George');
await db.order.delete(filter);
}
Batch delete cascade
When deleting records, sometimes associated data in related tables also needs to be removed. This cascade delete helps maintain database integrity.
import map from './map';
const db = map.sqlite('demo.db');
deleteRows();
async function deleteRows() {
const filter = db.order.deliveryAddress.name.eq('George');
await db.order.deleteCascade(filter);
}
Batch delete by primary key
For efficiency, you can also delete records directly if you know their primary keys.
import map from './map';
const db = map.sqlite('demo.db');
deleteRows();
async function deleteRows() {
db.customer.delete([{id: 1}, {id: 2}]);
}
In the browser
You can use Orange in the browser by using the adapter for Express or Hono. Instead of sending raw SQL queries from the client to the server, this approach records the method calls in the client. These method calls are then replayed at the server, ensuring a higher level of security by not exposing raw SQL on the client side.
Raw sql queries, raw sql filters and transactions are disabled at the http client due to security reasons. If you would like Orange to support other web frameworks, like nestJs, fastify, etc, please let me know.
📄 server.ts
import map from './map';
import { json } from 'body-parser';
import express from 'express';
import cors from 'cors';
const db = map.sqlite('demo.db');
express().disable('x-powered-by')
.use(json({ limit: '100mb' }))
.use(cors())
//for demonstrational purposes, authentication middleware is not shown here.
.use('/orange', db.express())
.listen(3000, () => console.log('Example app listening on port 3000!'));
📄 browser.ts
import map from './map';
const db = map.http('http://localhost:3000/orange');
updateRows();
async function updateRows() {
const order = await db.order.getOne({
where: x => x.lines.any(line => line.product.startsWith('Magic wand'))
.and(x.customer.name.startsWith('Harry'),
lines: true
});
order.lines.push({
product: 'broomstick',
amount: 300,
});
await order.saveChanges();
}
Hono adapter
You can host the same HTTP endpoint with Hono by replacing db.express() with db.hono(). The browser client setup stays the same (map.http(...)), so you can reuse the browser.ts example above.
📄 server.ts
import map from './map';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { serve } from '@hono/node-server';
const db = map.sqlite('demo.db');
const app = new Hono();
app.use('/orange', cors());
app.use('/orange/*', cors());
// for demonstrational purposes, authentication middleware is not shown here.
app.all('/orange', db.hono());
app.all('/orange/*', db.hono());
serve({ fetch: app.fetch, port: 3000 });
baseFilter and transaction hooks are also supported in db.hono({...}), using Hono-style request/response objects.
Interceptors and base filter
In the next setup, HTTP interceptors are employed on the client side to add an Authorization header of requests. Meanwhile, on the server side, an Express middleware (validateToken) is utilized to ensure the presence of the Authorization header, while a base filter is applied on the order table to filter incoming requests based on the customerId extracted from this header. This combined approach enhances security by ensuring that users can only access data relevant to their authorization level and that every request is accompanied by a token. In real-world applications, it’s advisable to use a more comprehensive token system and expand error handling to manage a wider range of potential issues.
One notable side effect compared to the previous example, is that only the order table is exposed for interaction, while all other potential tables in the database remain shielded from direct client access (except for related tables). If you want to expose a table without a baseFilter, just set the tableName to an empty object.
📄 server.ts
import map from './map';
import { json } from 'body-parser';
import express from 'express';
import cors from 'cors';
const db = map.sqlite('demo.db');
express().disable('x-powered-by')
.use(json({ limit: '100mb' }))
.use(cors())
.use('/orange', validateToken)
.use('/orange', db.express({
order: {
baseFilter: (db, req, _res) => {
const customerId = Number.parseInt(req.headers.authorization.split(' ')[1]); //Bearer 2
return db.order.customerId.eq(Number.parseInt(customerId));
}
}
}))
.listen(3000, () => console.log('Example app listening on port 3000!'));
function validateToken(req, res, next) {
// For demo purposes, we're just checking against existence of authorization header
// In a real-world scenario, this would be a dangerous approach because it bypasses signature validation
const authHeader = req.headers.authorization;
if (authHeader)
return next();
else
return res.status(401).json({ error: 'Authorization header missing' });
}
📄 browser.ts
import map from './map';
const db = map.http('http://localhost:3000/orange');
updateRows();
async function updateRows() {
db.interceptors.request.use((config) => {
// For demo purposes, we're just adding hardcoded token
// In a real-world scenario, use a proper JSON web token
config.headers.Authorization = 'Bearer 2' //customerId
return config;
});
db.interceptors.response.use(
response => response,
(error) => {
if (error.response && error.response.status === 401) {
console.dir('Unauthorized, dispatch a login action');
//redirectToLogin();
}
return Promise.reject(error);
}
);
const order = await db.order.getOne({
where: x => x.lines.any(line => line.product.startsWith('Magic wand'))
.and(db.order.customer.name.startsWith('Harry')),
lines: true
});
order.lines.push({
product: 'broomstick',
amount: 300
});
await order.saveChanges();
}
Row Level Security
You can enforce tenant isolation at the database level by combining Postgres RLS with Express hooks. The example below mirrors the “Interceptors and base filter” style by putting the tenant id in a (fake) token on the client, then extracting it on the server and setting it inside the transaction. This is convenient for a demo because we can seed data and prove rows are filtered. In a real application you must validate signatures and derive tenant id from a trusted identity source, not from arbitrary client input.
📄 setup.sql
create role rls_app_user nologin;
create table tenant_data (
id serial primary key,
tenant_id int not null,
value text not null
);
alter table tenant_data enable row level security;
create policy tenant_data_tenant on tenant_data
using (tenant_id = current_setting('app.tenant_id', true)::int);
grant select, insert, update, delete on tenant_data to rls_app_user;
insert into tenant_data (tenant_id, value) values
(1, 'alpha'),
(1, 'beta'),
(2, 'gamma');
📄 server.ts
import map from './map';
import { json } from 'body-parser';
import express from 'express';
import cors from 'cors';
const db = map.postgres('postgres://postgres:postgres@localhost/postgres');
express().disable('x-powered-by')
.use(json({ limit: '100mb' }))
.use(cors())
.use('/orange', validateToken)
.use('/orange', db.express({
hooks: {
transaction: {
//beforeBegin: async (db, req) => ...,
afterBegin: async (db, req) => {
const tenantId = Number.parseInt(String(req.user?.tenantId ?? ''), 10);
if (!Number.isFinite(tenantId)) throw new Error('Missing tenant id');
await db.query('set local role rls_app_user');
await db.query({
sql: 'select set_config(\'app.tenant_id\', ?, true)',
parameters: [String(tenantId)]
});
},
//beforeCommit: async (db, req) => ...,
//afterCommit: async (db, req) => ...,
// afterRollback: async (db, req, error) => {
// console.dir(error);
// }
}
}
}))
.listen(3000, () => console.log('Example app listening on port 3000!'));
function validateToken(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader) return res.status(401).json({ error: 'Authorization header missing' });
try {
const token = authHeader.replace(/^Bearer\s+/i, '');
const payload = decodeFakeJwt(token); // demo-only, do not use in production
req.user = { tenantId: String(payload.tenantId) };
return next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
}
function decodeFakeJwt(token) {
// Demo-only format: "tenant:<id>"
const match = /^tenant:(\d+)$/.exec(token);
if (!match) throw new Error('Invalid demo token');
return { tenantId: Number(match[1]) };
}
📄 browser.ts
import map from './map';
const db = map.http('http://localhost:3000/orange');
db.interceptors.request.use((config) => {
// Demo-only token: payload carries the tenant id so we can verify filtering
config.headers.Authorization = 'Bearer tenant:1';
return config;
});
const rows = await db.tenant_data.getMany();
// rows => [{ id: 1, tenant_id: 1, value: 'alpha' }, { id: 2, tenant_id: 1, value: 'beta' }]
Fetching strategies
Efficient data retrieval is crucial for the performance and scalability of applications. The fetching strategy gives you the freedom to decide how much related data you want to pull along with your primary request. Below are examples of common fetching strategies, including fetching entire relations and subsets of columns. When no fetching strategy is present, it will fetch all columns without its relations.
Including a relation
This example fetches orders and their corresponding delivery addresses, including all columns from both entities.
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.order.getMany({
deliveryAddress: true
});
}
Including a subset of columns
In scenarios where only specific fields are required, you can specify a subset of columns to include. In the example below, orderDate is explicitly excluded, so all other columns in the order table are included by default. For the deliveryAddress relation, only countryCode and name are included, excluding all other columns. If you have a mix of explicitly included and excluded columns, all other columns will be excluded from that table.
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.order.getMany({
orderDate: false,
deliveryAddress: {
countryCode: true,
name: true
}
});
}
Ad-hoc relations with lexical current and root scope
Return db.table.many() or db.table.one() from a fetch-strategy callback to add a read-only relation to a getMany or getOne result without declaring a mapped relation. The target can be any mapped table. The callback’s first parameter is the current row that owns the ad-hoc property, while its context contains db and the root row returned by the top-level query.
The ad-hoc relation can be declared directly on the top-level result. In this example, recentOrders does not need to be a mapped relation:
const customers = await db.customer.getMany({
name: true,
recentOrders: (customer, { db }) => db.order.many({
where: order => order.customerId.eq(customer.id),
orderBy: 'id',
limit: 5
})
});
The limit is applied independently for each customer. This form is batched, so it does not issue one query per customer: Orange fetches the customers first and then their recent orders in batches of up to 200 distinct scope tuples, or fewer when required by the database’s parameter limit.
Ad-hoc relations can also be nested beneath mapped relations and refer to the root row. Here, orders is mapped, while affordableLines and firstLine are ad-hoc:
const customers = await db.customer.getMany({
name: true,
orders: {
orderBy: 'id',
affordableLines: (order, { db, root }) => db.orderLine.many({
where: line =>
line.orderId.eq(order.id)
.and(line.amount.lt(root.balance)),
orderBy: 'id',
limit: 5
}),
firstLine: (order, { db }) => db.orderLine.one({
where: line => line.orderId.eq(order.id),
orderBy: 'id'
})
}
});
The callback form is required; a descriptor cannot be placed directly in the strategy as matchingLines: db.orderLine.many(...). TypeScript derives the exact current-row and root types from the property’s placement. The inner where callback uses the same syntax and relation access as any ordinary filter. A nested ad-hoc callback may capture and reuse current-row variables from any outer callback; their lexical scope is preserved through batching and HTTP transport.
many() returns an array and one() returns one row or null. The property name must not collide with a mapped column, mapped relation, or reserved strategy property. Their strategies support column selection, mapped relations, nested ad-hoc relations, where, orderBy, limit, and offset. Pagination is applied independently for each parent. Row locking is not available because ad-hoc projections are read-only. saveChanges() re-runs the ad-hoc strategy for affected rows and replaces those projection values with fresh results.
Orange automatically selects and removes the hidden scope columns needed by the query; you do not need to include primary keys manually. Scope-dependent filters are evaluated against an internal, owner-tagged scope table and batched in chunks. The internal owner tag attaches every target row to its exact lexical scope values, including composite correlations, ordinary non-key equality fields, non-equality filters, and duplicate correlation values under different roots. one(), limit, and offset are partitioned per owner in the database where supported; SAP ASE keeps the query batched and applies the final page in memory. Scope references owned by a nested mapped relation may use a conservative per-owner fallback. The same syntax works through the Express and Hono HTTP adapters, including target-table baseFilter rules.
Basic filters
Filters are a versatile tool for both data retrieval and bulk deletions. They allow for precise targeting of records based on specific criteria and can be combined with operators like any and exists and even raw sql for more nuanced control. Filters can also be nested to any depth, enabling complex queries that can efficiently manage and manipulate large datasets. This dual functionality enhances database management by ensuring data relevance and optimizing performance.
Equal
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.customer.getMany({
where x => x.name.equal('Harry')
});
}
Not equal
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.customer.getMany({
where x => x.name.notEqual('Harry')
});
}
Contains
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.customer.getMany({
where: x => x.name.contains('arr')
});
}
Starts with
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const filter = db.customer.name.startsWith('Harr');
const rows = await db.customer.getMany({
where: x => x.name.startsWith('Harr')
});
}
Ends with
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.customer.getMany({
where: x => x.name.endsWith('arry')
});
}
Greater than
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.order.getMany({
where: x => x.orderDate.greaterThan('2023-07-14T12:00:00')
});
}
Greater than or equal
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.order.getMany({
where: x => x.orderDate.greaterThanOrEqual('2023-07-14T12:00:00')
});
}
Less than
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.order.getMany({
where: x => x.orderDate.lessThan('2023-07-14T12:00:00')
});
}
Less than or equal
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.order.getMany({
where: x => x.orderDate.lessThanOrEqual('2023-07-14T12:00:00')
});
}
Between
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.order.getMany({
where: x => x.orderDate.between('2023-07-14T12:00:00', '2024-07-14T12:00:00')
});
}
Column-to-column filters
You can compare one column to another column instead of comparing to a constant value.
This works both on the same table and across relations.
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const sameName = await db.order.getMany({
where: x => x.deliveryAddress.name.eq(x.customer.name)
});
const containsName = await db.order.getMany({
where: x => x.deliveryAddress.name.contains(x.customer.name)
});
const withColumnBound = await db.customer.getMany({
where: x => x.balance.between(x.id, 180)
});
}
In
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.order.getMany({
where: x => x.customer.name.in('George', 'Harry')
});
}
Raw sql filter
You can use the raw SQL filter alone or in combination with a regular filter.
Here the raw filter queries for customer with name ending with “arry”. The composite filter combines the raw SQL filter and a regular filter that checks for a customer balance greater than 100. It is important to note that due to security precautions aimed at preventing SQL injection attacks, using raw SQL filters directly via browser inputs is not allowed. Attempting to do so will result in an HTTP status 403 (Forbidden) being returned.
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rawFilter = {
sql: 'name like ?',
parameters: ['%arry']
};
const rowsWithRaw = await db.customer.getMany({
where: () => rawFilter
});
const rowsWithCombined = await db.customer.getMany({
where: x => x.balance.greaterThan(100).and(rawFilter)
});
}
Relation filters
Relation filters offer a dynamic approach to selectively include or exclude related data based on specific criteria. In the provided example, all orders are retrieved, yet it filters the order lines to only include those that feature products with "broomstick" in their description. By setting deliveryAddress and customer to true, we also ensure the inclusion of these related entities in our result set.
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const orders = await db.order.getMany({
lines: {
where: x => x.product.contains('broomstick')
},
deliveryAddress: true,
customer: true
});
}
And, or, not, exists
These operators serve as the backbone for constructing complex queries that allow for more granular control over the data fetched from the database. The examples provided below are self-explanatory for anyone familiar with basic programming concepts and database operations. The design philosophy underscores the importance of clear, readable code that doesn't sacrifice power for simplicity.
And
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.order.getMany({
where: x => x.customer.name.equal('Harry')
.and(x.orderDate.greaterThan('2023-07-14T12:00:00'))
});
}
Or
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.order.getMany({
where: y => y.customer( x => x.name.equal('George')
.or(x.name.equal('Harry')))
});
}
Not
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.order.getMany({
where: y => y.customer(x => x.name.equal('George')
.or(x.name.equal('Harry')))
.not()
});
}
Exists
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.order.getMany({
where: x => x.deliveryAddress.exists()
});
}
Any, all, none
These operators are used in scenarios involving relationships within database records.
Any
The any operator is employed when the objective is to find records where at least one item in a collection meets the specified criteria.
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.order.getMany({
where: y => y.lines.any(x => x.product.contains('guitar'))
});
}
All
Conversely, the all operator ensures that every item in a collection adheres to the defined condition.
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.order.getMany({
where: y => y.lines.all(x => x.product.contains('a'))
});
}
None
The none operator, as the name suggests, is used to select records where not a single item in a collection meets the condition.
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.order.getMany({
where: y => y.lines.none(x => x.product.equal('Magic wand'))
});
}
Count
Use count on a relation in a filter to compare how many related rows match a condition.
import map from './map';
const db = map.sqlite('demo.db');
getRows();
async function getRows() {
const rows = await db.order.getMany({
where: x => x.lines.count().le(1)
.and(x.lines.count(line => line.product.contains('guitar')).eq(1))
});
}
Transactions
We initiate a database transaction using db.transaction.
Within the transaction, a customer is retrieved and its balance updated using the tx object to ensure operations are transactional.
An error
README 内容过长,已截取前 64 KB。完整内容请前往 GitHub 仓库查看。
评论