@chickyky/arangoose
v0.1.12
Published
TypeScript-first ODM/ORM for ArangoDB, inspired by Mongoose
Readme
@chickyky/arangoose
TypeScript-first ODM/ORM for ArangoDB, inspired by Mongoose. Built on the official arangojs driver.
Arangoose gives Mongoose developers a familiar API while embracing ArangoDB features: document collections, edge collections, AQL, and Mongo-style aggregation pipelines compiled to AQL.
Contents
- Install
- Quick start
- Connection management
- Schema definition
- Model definition
- CRUD operations
- Query builder
- Aggregation pipeline
- AQL support
- Transactions
- Edge collections
- Populate
- Middleware (hooks)
- Plugin system
- Repository pattern
- Debug logging
- Type safety
- Security notes
- Limitations & not implemented
- Companion packages
- API reference
Install
npm install @chickyky/arangoose arangojs
# or
pnpm add @chickyky/arangoose arangojsarangojs (>=8.0.0) is a peer dependency — install it yourself. Output is CommonJS and ships with type declarations.
Need a local server?
docker run --rm -p 8529:8529 -e ARANGO_ROOT_PASSWORD=test arangodb:3.12Quick start
import { connect, Schema, model } from '@chickyky/arangoose';
await connect({
url: 'http://localhost:8529',
database: 'mydb',
username: 'root',
password: 'password',
});
const UserSchema = new Schema({
email: { type: String, required: true, unique: true },
name: String,
age: Number,
createdAt: { type: Date, default: Date.now },
});
const UserModel = model('User', UserSchema, { collection: 'users' });
await UserModel.ensureCollection(); // creates the collection + declared indexes
const user = await UserModel.create({ email: '[email protected]', name: 'John' });
const found = await UserModel.findOne({ email: '[email protected]' });Collections are not created implicitly. Call ensureCollection() (or createCollection() / createIndexes()) once at startup for each model.
Connection management
import { connect, getConnection, disconnect } from '@chickyky/arangoose';
await connect({ url: 'http://localhost:8529', database: 'mydb' });
// named connections
await connect({ url: 'http://localhost:8530', database: 'reporting' }, 'reporting');
const reportingDb = getConnection('reporting'); // raw arangojs Database
await disconnect(); // closes + forgets the default connection
await disconnect('reporting');connect() builds the Database handle but performs no network round-trip — a bad URL or bad credentials surface on the first query, not here. To fail fast at startup:
const db = await connect(options);
await db.version(); // throws now if unreachable / unauthorisedModels always use the default connection.
Model,QueryandAggregatecallgetConnection()with no name. Named connections are usable via the rawarangojshandle only.
Schema definition
import { Schema } from '@chickyky/arangoose';
const PostSchema = new Schema({
title: { type: String, required: true },
status: { type: String, enum: ['draft', 'published'] },
author: { type: String, ref: 'User' }, // reference, see Populate
tags: Array,
meta: 'Mixed', // anything goes
address: {
// nested schema
street: { type: String, required: true },
city: String,
},
views: { type: Number, default: 0 },
createdAt: { type: Date, default: Date.now },
});Field types: String, Number, Boolean, Date, Array, Object, 'Mixed', and nested definition objects.
Field options: required, unique (declares a unique index), default (value or function), enum, ref (see Populate), validate (sync or async predicate returning false to reject).
Casting
Before validation, create() and update*() coerce input into the declared type — HTTP bodies, query strings and admin-panel forms carry no Date or number:
| Declared | Accepts | Result |
| --------- | -------------------------------- | --------- |
| Date | Date, ISO string, epoch number | Date |
| Number | numeric string | number |
| Boolean | 'true' / 'false' | boolean |
Nested definitions are cast recursively. Values that cannot be coerced (age: 'abc') are left alone so validation rejects them. This is what makes default: Date.now (which returns a number) work.
Validation
Validation runs before every write — full on create/createMany, partial on updateById/updateOne/updateMany (only fields present in the update are checked, so required is not re-enforced):
import { ValidationError } from '@chickyky/arangoose';
await UserModel.create({}); // ValidationError: field "email" is requiredrequired is only enforced for fields the schema declares. A missing nested object is accepted whole; a nested object that is present has its own required fields checked.
Indexes
unique: true on a field declares a unique index; schema.index() declares any other:
UserSchema.index({ fields: ['email'], unique: true });
UserSchema.index({ fields: ['createdAt'], type: 'persistent', sparse: true });
await UserModel.createIndexes(); // create declared indexes
await UserModel.ensureIndexes(); // alias for createIndexes()
await UserModel.syncIndexes(); // drop stale indexes, create missing onesTypes: persistent (default), hash, skiplist, ttl, geo, fulltext.
syncIndexes()is destructive. It drops every non-primary, non-edge index whose name is not declared on the schema — including indexes created by hand or by another tool. It returns the names it dropped.
Model definition
import { model, edgeModel } from '@chickyky/arangoose';
const UserModel = model('User', UserSchema, { collection: 'users' });
const FollowModel = edgeModel('Follow', FollowSchema, { collection: 'follows' });collection defaults to name.toLowerCase(). Models are registered by name in a process-global registry, which is how populate() resolves ref fields.
CRUD operations
await UserModel.create({ email: '[email protected]' });
await UserModel.createMany([{ email: '[email protected]' }, { email: '[email protected]' }]);
await UserModel.find({ age: { $gte: 18 } }); // Query, see below
await UserModel.findOne({ email: '[email protected]' });
await UserModel.findById(keyOrId);
await UserModel.count({ age: { $gte: 18 } });
await UserModel.exists({ email: '[email protected]' });
await UserModel.updateOne({ email: '[email protected]' }, { name: 'John' });
await UserModel.updateMany({ age: { $lt: 18 } }, { minor: true }); // -> number updated
await UserModel.updateById(key, { name: 'John' });
await UserModel.deleteOne({ email: '[email protected]' }); // -> boolean
await UserModel.deleteMany({ age: { $lt: 18 } }); // -> number deleted
await UserModel.deleteById(key);
await UserModel.upsert({ email: '[email protected]' }, { name: 'John' });findById accepts a _key or a full _id, and returns null when the document is missing (it does not throw).
upsert updates the first match or inserts { ...filter, ...data }. Operator sub-objects in the filter ({ age: { $gte: 18 } }) are dropped from the inserted document. It is a read-then-write, so it is not atomic — a unique index is what actually prevents duplicates under concurrency.
updateManyanddeleteManyfetch matching documents and then issue one request per document (N+1). Fine for admin-scale work; useModel.aqlfor bulk mutations on large result sets.
Query operators
$gte, $gt, $lte, $lt, $ne, $in, $nin. Anything else throws. A plain value means equality.
await UserModel.find({ age: { $gte: 18 }, role: { $in: ['admin', 'editor'] } });There is no $or, $and, $not, $regex or $exists — see Limitations.
Query builder
Model.find() returns a chainable, awaitable Query:
const users = await UserModel.find({ age: { $gte: 18 } })
.sort({ createdAt: -1, name: 1 }) // multiple keys, in declaration order
.skip(40)
.limit(20)
.select(['name', 'email']) // project(...) is an alias
.exec();
const page = await UserModel.find({}).paginate(2, 20);
// { data, total, page, pageSize } — `total` ignores skip/limitQuery is thenable, so await UserModel.find(filter) works without .exec().
Terminal methods: exec(), first(), count(), paginate(page, pageSize).
Modifiers: sort, skip, limit, select/project, populate, withDeleted, session, lean.
lean()is a no-op kept for Mongoose familiarity. Arangoose always returns plain objects — there is no hydrated document class, and therefore nodoc.save().
Aggregation pipeline
Mongo-style stages compiled to AQL:
const byStatus = await OrderModel.aggregate([
{ $match: { createdAt: { $gte: since } } },
{ $group: { _id: '$status', count: { $sum: 1 }, revenue: { $sum: '$amount' } } },
{ $sort: { revenue: -1 } },
{ $limit: 10 },
]).exec();Supported stages: $match, $sort, $skip, $limit, $project (1 include / 0 exclude), $unwind, $group, $lookup, and $raw for an escape hatch.
Accumulators: $sum (field ref or the literal 1 to count), $avg, $min, $max.
// group by several keys
{ $group: { _id: { status: '$status', region: '$region' }, n: { $sum: 1 } } }
// left join another collection
{ $lookup: { from: 'users', localField: 'userId', foreignField: '_key', as: 'user' } }
// raw AQL spliced into the pipeline
import { aql } from 'arangojs/aql';
{ $raw: aql`FILTER doc.score > ${threshold}` }Aggregate is thenable too, and takes .session(session).
AQL support
const emails = await UserModel.aql<string>`
FOR u IN users
FILTER u.age > ${18}
RETURN u.email
`;Interpolated values become bind parameters, exactly like arangojs' own aql helper. The result is fully materialised via cursor.all() — there is no streaming cursor API. Model.aql always uses the default connection and ignores sessions.
Transactions
Streaming transactions, Mongoose-style:
import { withTransaction } from '@chickyky/arangoose';
const order = await withTransaction({ write: ['orders', 'stock'] }, async (session) => {
const created = await OrderModel.create({ total: 100 }, session);
await StockModel.updateOne({ sku: 'X' }, { qty: 0 }, session);
return created;
});withTransaction commits on success and aborts if the callback throws. For manual control:
import { startSession } from '@chickyky/arangoose';
const session = await startSession({ write: ['orders'] });
try {
await OrderModel.create({ total: 100 }, session);
await session.commit();
} catch (err) {
await session.abort();
throw err;
}Every collection a transaction touches must be declared up front in { read, write, exclusive }. Pass the session to Model methods, or query.session(session) / aggregate.session(session).
Edge collections
const FollowSchema = new Schema({ since: Date });
const FollowModel = edgeModel('Follow', FollowSchema, { collection: 'follows' });
await FollowModel.ensureCollection(); // creates it as an edge collection
await FollowModel.create({ _from: userA._id, _to: userB._id, since: new Date() });Edge models are ordinary models over an edge collection — all CRUD, query and aggregation methods apply. Graph traversal has no dedicated API yet; use Model.aql:
const followers = await UserModel.aql`
FOR v, e, p IN 1..3 INBOUND ${userB._id} follows
RETURN { user: v, depth: LENGTH(p.edges) }
`;Populate
Declare a ref on a field, then resolve it on read:
const PostSchema = new Schema({
title: String,
author: { type: String, ref: 'User' }, // stores a `users/<key>` id
reviewers: { type: Array, ref: 'User' }, // array of ids
});
const PostModel = model('Post', PostSchema, { collection: 'posts' });
const posts = await PostModel.find({}).populate(['author', 'reviewers']);
// convenience parameter on single-document lookups
const post = await PostModel.findOne({ title: 'Hello' }, 'author');
const same = await PostModel.findById(key, 'author');Array-valued refs are populated element by element. Populating a field with no ref, or a ref naming a model that was never created via model()/edgeModel(), throws.
Populate issues one
findByIdper document per path (N+1). For hot paths, prefer$lookupin an aggregation or a hand-written AQL join.
Middleware (hooks)
UserSchema.pre('validate', function () {
/* ... */
});
UserSchema.post('validate', function () {
/* ... */
});
UserSchema.pre('save', async function () {
(this as { createdAt?: Date }).createdAt = new Date();
});
UserSchema.post('save', async function () {
console.log('saved');
});pre/post for validate, save, update, delete. Order on create and updateById:
pre(validate) → validate → post(validate) → pre(save|update) → write → post(save|update)If validation throws, post(validate) and the write are skipped.
this is the document draft in pre hooks and the written document in post hooks; on delete hooks it is { _key }. It is typed as unknown, so cast it — the example above shows the idiom. Hooks fire on create/update*/delete*; they do not fire for Model.aql or raw arangojs calls.
Plugin system
import { timestampPlugin, softDeletePlugin, auditPlugin, tenantPlugin } from '@chickyky/arangoose';
UserSchema.plugin(timestampPlugin); // createdAt / updatedAt
UserSchema.plugin(softDeletePlugin); // see below
UserSchema.plugin(auditPlugin); // logs writes via debug
UserSchema.plugin(tenantPlugin, { getTenantId: () => tenant }); // multi-tenantA plugin is just a function over the schema:
function myPlugin(schema: Schema, options?: MyOptions) {
schema.pre('save', function () {
/* ... */
});
}
UserSchema.plugin(myPlugin, { some: 'option' });Soft delete
softDeletePlugin (or schema.enableSoftDelete(field?)) turns deletes into an update that stamps a timestamp field instead of removing the document. find, count, exists and findById exclude soft-deleted documents:
UserSchema.plugin(softDeletePlugin); // field defaults to "deletedAt"
await UserModel.deleteById(key); // document survives, deletedAt is set
await UserModel.find({}); // excludes it
await UserModel.find({}).withDeleted(); // includes itAggregation pipelines are not soft-delete aware — add { $match: { deletedAt: null } } yourself.
Tenant scoping
tenantPlugin (or schema.enableTenantScope({ field, getTenantId })) stamps the tenant field on create and scopes find/count/exists to the current tenant, unless your filter already names that field:
UserSchema.plugin(tenantPlugin, { getTenantId: () => currentTenantId() });Not scoped: key-based operations (findById, updateById, deleteById), aggregation pipelines, and Model.aql. Enforce ownership yourself on those paths.
Repository pattern
import { BaseRepository } from '@chickyky/arangoose';
class UserRepository extends BaseRepository<User> {
constructor() {
super(UserModel);
}
findByEmail(email: string) {
return this.findOne({ email });
}
}
const user = await new UserRepository().findByEmail('[email protected]');BaseRepository provides findById, findOne, findMany, create, createMany, updateById, deleteById, count, exists. The underlying model is protected readonly model, so subclasses can drop to this.model.find(...), this.model.aggregate(...) or this.model.aql for anything richer.
Debug logging
Arangoose uses debug under the arangoose:* namespace:
DEBUG=arangoose:* node app.js # everything
DEBUG=arangoose:query node app.js # generated AQL + bind vars
DEBUG=arangoose:model node app.js # create/update/delete + model.aql()
DEBUG=arangoose:connection node app.js # connect/disconnect
DEBUG=arangoose:audit node app.js # auditPlugin outputarangoose:query prints the exact AQL and bind variables — the fastest way to see what a Query or Aggregate compiled to.
Type safety
The document type is a generic parameter; the schema definition is runtime metadata and is not inferred into it:
import { Schema, model, type Document } from '@chickyky/arangoose';
interface User extends Document {
// Document adds _key?/_id?/_rev?
email: string;
name?: string;
}
const UserSchema = new Schema<User>({
email: { type: String, required: true, unique: true },
name: String,
});
const UserModel = model<User>('User', UserSchema, { collection: 'users' });
const user = await UserModel.findOne({ email: '[email protected]' });
user?.email; // string | undefined — no castingFilters and updates are Partial<T>, so unknown fields are a compile error. Because the schema object is not reflected into the type, new Schema({...}) and new Schema<User>({...}) are interchangeable — the generic on model<User>() is what types the documents. Keeping the interface and the definition in sync is on you.
Security notes
Bind parameters cannot name attributes in AQL, so field, sort and collection names are spliced into the query as literals. Every such name is validated against /^[A-Za-z_$][\w$-]*(\.[A-Za-z_$][\w$-]*)*$/ and anything else throws:
await UserModel.find({ 'name == "a" OR true ? true : true': 'x' });
// Error: Arangoose: "..." is not a valid field or collection name.This matters because filter keys and sortBy frequently arrive straight from a query string. Without the check, a crafted key rewrites the FILTER clause and defeats soft-delete and tenant scoping.
Values are always bind parameters and are never interpolated. $raw aggregation stages and Model.aql are escape hatches — do not build either from user input.
Limitations & not implemented
Known gaps, so you can decide up front whether they matter:
- Query operators — no
$or,$and,$not,$regex,$exists,$elemMatch. Top-level keys are ANDed. - Graph API — no traversal, shortest-path or named-graph helpers. Edge collections work; traversals go through
Model.aql. - Documents are plain objects — no
doc.save(), no change tracking, no virtuals, no instance/static methods, no discriminators.lean()is a no-op. - Connections — models always use the default connection.
- Cursors — results are always fully materialised; no streaming or batching.
- Sub-document arrays —
Arrayis not typed element-wise, so array items are not validated or cast. populate,updateMany,deleteMany— N+1 request patterns.- Tests are unit tests —
arangojsis mocked, so the suite verifies the AQL that is built, not that ArangoDB accepts it. There is no integration suite; run changes against a real server.
Companion packages
| Package | What it is |
| ------------------------------------- | ------------------------------------------------------------------------------- |
| @chickyky/arangoose-nestjs | ArangooseModule, @InjectModel, @InjectRepository for NestJS 10 |
| @chickyky/arangoose-adminjs-adapter | AdminJS v7 database/resource adapter (needs Node >= 20.19; AdminJS is ESM-only) |
API reference
Everything is exported from the package root:
import {
// connection
connect,
getConnection,
disconnect,
startSession,
withTransaction,
Session,
// schema
Schema,
ValidationError,
// model
model,
edgeModel,
Model,
// query
Query,
Aggregate,
// repository
BaseRepository,
// plugins
timestampPlugin,
softDeletePlugin,
auditPlugin,
tenantPlugin,
// types
type Document,
type EdgeDocument,
type ConnectionOptions,
type ModelOptions,
type SchemaDefinition,
type SchemaField,
type IndexDefinition,
type AggregateStage,
type PaginateResult,
type SchemaPlugin,
} from '@chickyky/arangoose';Development
pnpm install
pnpm --filter @chickyky/arangoose build # tsc + tsc-alias
pnpm --filter @chickyky/arangoose test # vitestInside this package, imports use the @/* alias (mapped to src/*); tsc-alias rewrites it to relative paths in dist/, so the build needs no runtime path resolution. vitest.config.ts mirrors the alias for tests.
src/
connection/ # connect / getConnection / disconnect, Session + transactions
schema/ # Schema, casting, validation, hooks, index declarations
model/ # Model, model registry, populate
query/ # Query builder, Aggregate, filter builder
repository/ # BaseRepository
plugins/ # timestamp / soft-delete / audit / tenant
utils/ # debug loggers, AQL identifier guardLayering: Connection → Schema → Model → Query → Repository → Application.
