Skip to content

Search docs

Jump between documentation pages.

Browse docs

Use Ottoman with DaloyJS

Ottoman is an ODM for Couchbase. Use it when your application stores JSON documents in Couchbase buckets, scopes, and collections and you want model definitions, validation, query helpers, and indexes around that document layer. This guide targets Ottoman 2 (ottoman@latest is 2.5 as of October 2026).

Ottoman setup
  1. 01Installpnpm add ottoman couchbase
  2. 02Schema & modelnew Schema · model('User')
  3. 03Pluginconnect · decorate('db') · onClose
  4. 04Augment stateinterface AppState { db }
  5. 05Use in routesstate.db.User.findById()
Ottoman connects once at startup and handlers read the decorated state.db model surface. Collections and indexes are created by ottoman.start() in a separate deploy step, not on every boot.

1. Install

bash
pnpm add ottoman couchbase

2. Define a schema and model

Set the collection per model in the third model() argument. Ottoman resolves the name as model option, then the Ottoman instance option, then the model name.

src/db/ottoman.tsts
import { Ottoman, Schema } from "ottoman";

export const ottoman = new Ottoman();

const userSchema = new Schema({
  email: { type: String, required: true },
  name: { type: String, required: false },
});

// A N1QL (GSI) index on email. It serves the findOne({ email }) query below.
userSchema.index.findByEmail = {
  by: "email",
  type: "n1ql",
};

export const User = ottoman.model("User", userSchema, { collectionName: "users" });
export const db = { ottoman, User };

3. Create an Ottoman plugin

Connect once during app startup, decorate the app with the models you want handlers to use, and close the connection on shutdown. Collections and indexes are created separately, below.

src/db/plugin.tsts
import type { App } from "@daloyjs/core";
import { db, ottoman } from "./ottoman.ts";

// Call with the root app, not through app.register(): a decoration made
// inside a registered plugin is visible only to that plugin's own routes.
export async function ottomanPlugin(app: App) {
  await ottoman.connect({
    connectionString: process.env.COUCHBASE_CONNECTION_STRING!,
    bucketName: process.env.COUCHBASE_BUCKET!,
    // A data-only user: read and write on the app's collections, nothing else.
    username: process.env.COUCHBASE_USERNAME!,
    password: process.env.COUCHBASE_PASSWORD!,
  });

  app.decorate("db", db);

  app.onClose(async () => {
    await ottoman.close();
  });
}

onClose() runs on the Node.js, Bun, and Deno adapters only; see Serverless and edge lifecycle for the serverless platforms.

4. Augment app state types

Add the declare module block to the same module that exports db, not to a separate .d.ts file. Declaration files are exempt from type-checking when skipLibCheck is on (the scaffolded default), so a broken import inside a .d.ts fails silently and state.db degrades to any.

src/db/ottoman.tsts
// the module that exports db
declare module "@daloyjs/core" {
  interface AppState {
    db: typeof db;
  }
}

5. Use it in routes

In the Ottoman source, findById() and findOne() reject with the Couchbase SDK's DocumentNotFoundError when nothing matches, rather than resolving to null. Map that error to a 404, and keep a null check as a fallback.

src/server.tsts
import { z } from "zod";
import { DocumentNotFoundError } from "couchbase";
import { App, NotFoundError } from "@daloyjs/core";
import { serve } from "@daloyjs/core/node";
import { ottomanPlugin } from "./db/plugin.ts";

const UserSchema = z.object({
  id: z.string(),
  email: z.email(),
  name: z.string().nullable(),
});

const app = new App();
await ottomanPlugin(app);

async function orNotFound<T>(lookup: Promise<T | null | undefined>): Promise<T> {
  try {
    const doc = await lookup;
    if (!doc) throw new NotFoundError("User not found");
    return doc;
  } catch (err) {
    if (err instanceof DocumentNotFoundError) throw new NotFoundError("User not found");
    throw err;
  }
}

app.get(
  "/users/:id",
  {
    operationId: "getUser",
    request: { params: z.object({ id: z.string().min(1).max(250) }) },
    responses: {
      200: { description: "Found", body: UserSchema },
      404: { description: "Not found" },
    },
  },
  async ({ params, state }) => {
    const user = await orNotFound(state.db.User.findById(params.id));
    return {
      status: 200,
      body: { id: user.id, email: user.email, name: user.name ?? null },
    };
  },
);

app.get(
  "/users/by-email/:email",
  {
    operationId: "getUserByEmail",
    request: { params: z.object({ email: z.email() }) },
    responses: {
      200: { description: "Found", body: UserSchema },
      404: { description: "Not found" },
    },
  },
  async ({ params, state }) => {
    // Served by the findByEmail N1QL index declared on the schema.
    const user = await orNotFound(state.db.User.findOne({ email: params.email }));
    return { status: 200, body: { id: user.id, email: user.email, name: user.name ?? null } };
  },
);

await app.ready();
serve(app, { port: 3000 });

Keep filter values primitive (z.string(), z.email()) so a request can never inject a query operator object into findOne() or find().

Indexes are a deploy step

ottoman.start() creates the collections and indexes your models declare. Run it from a deploy or migration script, not on every app boot: concurrent instances race to build the same indexes, and the credential that may create indexes and collections should not be the one your API runs with.

scripts/ensure-indexes.tsts
import { ottoman } from "../src/db/ottoman.ts";

await ottoman.connect({
  connectionString: process.env.COUCHBASE_CONNECTION_STRING!,
  bucketName: process.env.COUCHBASE_BUCKET!,
  // A deploy-only user allowed to manage collections and query indexes.
  username: process.env.COUCHBASE_ADMIN_USERNAME!,
  password: process.env.COUCHBASE_ADMIN_PASSWORD!,
});
await ottoman.start();
await ottoman.close();

Transactions

Ottoman is best for model-centric Couchbase document access. If a workflow requires Couchbase distributed transactions, expose the Couchbase SDK objects you need through the same plugin and keep that transaction boundary inside the handler that owns the unit of work.

Runtime constraints

Ottoman depends on the Couchbase Node.js SDK, which ships a native module, so it is a Node.js-first ODM. It is not a fit for Cloudflare Workers, and we have not verified Bun, Deno, or the serverless platforms. For edge-compatible data access, use the SQL ORM overview and choose a compatible client.

Compare with Mongoose or the MongoDB driver for MongoDB, the SQL ORM overview, or return to the ODM overview.

Edit this page on GitHub

Last updated