npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

npm install @chickyky/arangoose arangojs
# or
pnpm add @chickyky/arangoose arangojs

arangojs (>=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.12

Quick 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 / unauthorised

Models always use the default connection. Model, Query and Aggregate call getConnection() with no name. Named connections are usable via the raw arangojs handle 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 required

required 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 ones

Types: 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.

updateMany and deleteMany fetch matching documents and then issue one request per document (N+1). Fine for admin-scale work; use Model.aql for 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/limit

Query 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 no doc.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 findById per document per path (N+1). For hot paths, prefer $lookup in 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-tenant

A 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 it

Aggregation 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 output

arangoose: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 casting

Filters 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 arraysArray is not typed element-wise, so array items are not validated or cast.
  • populate, updateMany, deleteMany — N+1 request patterns.
  • Tests are unit testsarangojs is 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    # vitest

Inside 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 guard

Layering: Connection → Schema → Model → Query → Repository → Application.