---
name: edinburgh
description: Expert guidance for using the Edinburgh ORM — a high-performance TypeScript ORM built on LMDB. Covers model definitions, typed fields, transactions, indexes, links, schema migration, and advanced patterns.
---
# Edinburgh
**TypeScript objects that live in the database.**

Edinburgh blurs the line between in-memory objects and database records. Define a model class, and its instances *are* the database rows. They are read directly from a memory-mapped [LMDB](http://www.lmdb.tech/doc/) store on first access, and mutations are written back in an ACID transaction on commit. There is no SQL layer, no query builder, no network round-trip, and no result-set marshalling. A primary-key lookup completes in about 1 µs.

This makes problems like n+1 queries irrelevant: traversing `post.author.department.manager` is just a chain of microsecond memory-mapped reads, not a cascade of network calls.

Built on [OLMDB](https://github.com/vanviegen/olmdb) (an optimistic-locking wrapper around LMDB).

- **Objects are records**: model fields are backed by memory-mapped storage; no serialization boundary between your code and the database
- **Sub-microsecond reads**: embedded B+ tree in the same process, no network hop, no query parsing
- **Type-safe at every layer**: TypeScript inference at compile time, runtime validation at write time
- **First-class relationships**: `E.link(OtherModel)` fields load lazily and transparently on access
- **Indexes**: primary, unique, and secondary indexes with efficient range queries
- **ACID transactions**: optimistic locking with automatic retry on conflict (up to 6 attempts)
- **Zero-downtime schema evolution**: old rows are lazily migrated on read; no batch DDL required

## Quick Demo
```typescript
import * as E from "edinburgh";

// Initialize the database (optional, defaults to ".edinburgh")
E.init("./my-database");

const User = E.defineModel("User", class {
    id = E.field(E.identifier);
    name = E.field(E.string);
    age = E.field(E.number);
    email = E.field(E.opt(E.string));
    // Optional link to another instance of this model (needs a function as `User` is not defined yet at this point)
    supervisor = E.field(E.opt(E.link(() => User)));
    // A field with a more elaborate type. In TypeScript: `User | User[] | "unknown" | "whatever"`, defaulting to "unknown".
    something = E.field(
        E.or(
            E.link(() => User),
            E.array(E.link(() => User)),
            E.literal("unknown"),
            E.literal("whatever")
        ),
        { default: "unknown" }
    );
}, {
    pk: "id",
    unique: {
        email: "email",
    },
});

await E.transact(() => {
    // Unique 'id' values are auto-generated if not provided
    const boss = new User({ name: "Big Boss", age: 50 });
    new User({
        name: "John Doe",
        age: 41,
        email: "john@example.com",
        supervisor: boss, // Link to another model instance
    });
    // Newly instantiated models are automatically saved to the database on transaction commit
});

await E.transact(() => {
    // Query by unique index
  const john = User.getBy("email", "john@example.com")!;

    // The transaction will retry if there's a conflict, such as another transaction
    // modifying the same user (from another async function or another process)
    john.age++;

    // The supervisor object is lazy loaded on first access
    console.log(`${john.supervisor!.name} is ${john.name}'s supervisor`);
});    
```

## Tutorial


### Defining Models

A model is defined using the `E.defineModel()` function by passing it..
- a consistent table name,
- an (anonymous) class containing `E.field` database properties and optionally regular properties/methods, and
- optional key/index configuration.

```typescript
import * as E from "edinburgh";

const User = E.defineModel("User", class {
  id = E.field(E.identifier);
  name = E.field(E.string);
  email = E.field(E.string);
  age = E.field(E.number);
}, {
  pk: "id",
  unique: {
    email: "email",
  },
});
// Add this if you want to use User as a type annotation (e.g. `let u: User`).
// Not needed just to call User.get(), User.find(), new User(), etc.
type User = InstanceType<typeof User>;
```

Instance fields are declared with `E.field(type, options?)`. Available types:

| Type | TypeScript type | Notes |
|------|----------------|-------|
| `E.string` | `string` | |
| `E.orderedString` | `string` | Lexicographic sort in indexes; no null bytes |
| `E.number` | `number` | |
| `E.boolean` | `boolean` | |
| `E.dateTime` | `Date` | Defaults to `new Date()` |
| `E.identifier` | `string` | Auto-generated 8-char unique ID |
| `E.opt(T)` | `T \| undefined` | Makes any type optional |
| `E.or(A, B, ...)` | `A \| B \| ...` | Union type; args can be types or literal values |
| `E.literal(v)` | literal type | Constant value; defaults to that value |
| `E.array(T)` | `T[]` | Optional `{min, max}` constraints |
| `E.set(T)` | `Set<T>` | Optional `{min, max}` constraints |
| `E.record(T)` | `Record<string \| number, T>` | Key-value object with string/number keys |
| `E.object({k: T, ...})` | `{k: T, ...}` | Fixed-shape struct; keys are part of the schema (stored compactly, no per-record keys). `E.opt` members become optional properties |
| `E.link(Model)` | `Model` | Foreign key, lazy-loaded on access |

#### Defaults

```typescript
const Post = E.defineModel("Post", class {
  id = E.field(E.identifier); // auto-generated
  title = E.field(E.string);
  status = E.field(E.or("draft", "published"), {default: "draft"});
  tags = E.field(E.array(E.string), {default: () => []}); // use function for mutable defaults
  createdAt = E.field(E.dateTime); // dateTime defaults to new Date()
}, { pk: "id" });
```

### Transactions

All database operations must run inside `E.transact()`:

```typescript
// Initialize (optional — defaults to ".edinburgh" directory)
E.init("./my-database");

// Create
await E.transact(() => {
  // User.id is auto-generated
  new User({name: "Alice", email: "alice@example.com", age: 30});
});

// Read + Update
await E.transact(() => {
  const user = User.getBy("email", "alice@example.com");
  if (user) user.age++;
});

// Return values from transactions
const name = await E.transact(() => {
  const user = User.getBy("email", "alice@example.com");
  return user?.name;
});
```

Transactions auto-retry on conflict (up to 6 times by default). Keep transaction functions idempotent.

### Indexes

Edinburgh supports three index types:

```typescript
const Product = E.defineModel("Product", class {
  sku = E.field(E.string);
  name = E.field(E.string);
  category = E.field(E.string);
  price = E.field(E.number);
}, {
  pk: "sku",
  unique: { name: "name" },
  index: { category: "category" },
});
```

If no `pk` is provided, Edinburgh auto-creates one on an `id` field, adding it as an `E.identifier` field if needed.

#### Lookups

```typescript
await E.transact(() => {
  // Primary key lookup
  const p1 = Product.get("SKU-001");

  // Unique index lookup
  const p2 = Product.getBy("name", "Widget");

  // All return undefined if not found
});
```

If a primary key or named index includes a `link(...)` field, lookup helpers either accept the linked row's instance object or its primary key. For linked models with composite primary keys, pass the full tuple in that slot:

```typescript
await E.transact(() => {
  // So instead of..
  const post1 = Post.getBy("author", Author.get(user.id), "Hello World");
  // We can do..
  const post2 = Post.getBy("author", user.id, "Hello World");
  // Or..
  const post3 = Post.getBy("author", [user.id], "Hello World");

  // For an index that includes a link with a composite primary key..
  const page = Comment.getBy("target", ["docs", "intro"], "overview");
});
```

#### Range Queries

Primary-key queries use `.find()`. Named unique and secondary indexes use `.findBy(name, ...)`:

```typescript
await E.transact(() => {
  // Exact match
  for (const p of Product.findBy("category", {is: "electronics"})) {
    console.log(p.name);
  }

  // Range (inclusive)
  for (const p of Product.find({from: "A", to: "M"})) {
    console.log(p.sku);
  }

  // Exclusive bounds
  for (const p of Product.find({after: "A", before: "M"})) { ... }

  // Open-ended
  for (const p of Product.find({from: "M"})) { ... }

  // Reverse
  for (const p of Product.find({reverse: true})) { ... }

  // Count and fetch helpers
  const count = Product.findBy("category", {is: "electronics"}).count();
  const first = Product.findBy("category", {is: "electronics"}).fetch(); // first match or undefined
});
```

#### Composite Primary Keys

```typescript
const Event = E.defineModel("Event", class {
  year = E.field(E.number);
  month = E.field(E.number);
  id = E.field(E.identifier);
  title = E.field(E.string);
}, {
  pk: ["year", "month", "id"] as const,
});

await E.transact(() => {
  // Prefix matching — find all events in 2025
  for (const e of Event.find({is: [2025]})) { ... }

  // Find events in March 2025
  for (const e of Event.find({is: [2025, 3]})) { ... }
});
```

#### Non-Persistent Properties

You can freely add regular methods, getters, and other non-persistent properties to model classes. These work normally in JavaScript but are **not stored in the database** and **not synchronized** across transactions or processes.

```typescript
const User = E.defineModel("User", class {
  firstName = E.field(E.string);
  lastName = E.field(E.string);

  // Non-persisted property
  cachedFullName?: string;

  get fullName(): string {
    this.cachedFullName ??= `${this.firstName} ${this.lastName}`;
    return this.cachedFullName;
  }

  greet(): string {
    return `Hello, ${this.fullName}!`;
  }
});
```

#### Computed Indexes

Instead of naming fields, you can pass a function as an index specification. The function receives a model instance and returns an **array** of index key values. Each element creates a separate index entry, enabling multi-value indexes. Return `[]` to skip indexing for that instance (partial index).

```typescript
const Article = E.defineModel("Article", class {
  id = E.field(E.identifier);
  firstName = E.field(E.string);
  lastName = E.field(E.string);
  title = E.field(E.string);
  email = E.field(E.opt(E.string));
}, {
  pk: "id",
  unique: {
    fullName: (a: any) => [`${a.firstName} ${a.lastName}`], // computed covering unique index
  },
  index: {
    domain: (a: any) => a.email ? [a.email.split("@")[1]] : [], // computed partial index
    word: (a: any) => a.title.toLowerCase().split(" "), // computed multi-index
  },
});

await E.transact(() => {
  new Article({ firstName: "Jane", lastName: "Doe", title: "Hello World", email: "jane@acme.com" });

  // Lookup via computed unique index
  const jane = Article.getBy("fullName", "Jane Doe");

  // Multi-value: each word in the title is indexed separately
  for (const a of Article.findBy("word", {is: "hello"})) { ... }

  // Partial index: articles without email are skipped
  for (const a of Article.findBy("domain", {is: "acme.com"})) { ... }
});
```

### Relationships

Use `E.link(Model)` for foreign keys. Use a thunk (a function that just returns a value) for forward references when needed:

```typescript
const Author = E.defineModel("Author", class {
  id = E.field(E.identifier);
  name = E.field(E.string);
}, { pk: "id" });

const Book = E.defineModel("Book", class {
  id = E.field(E.identifier);
  title = E.field(E.string);
  author = E.field(E.link(Author));
}, { pk: "id" });

await E.transact(() => {
  const author = new Author({name: "Tolkien"});
  const book = new Book({title: "The Hobbit", author});

  // Later: linked models are lazy-loaded on property access
  const b = Book.get(book.id)!;
  console.log(b.author.id);    // no need to load yet..
  console.log(b.author.name);  // loads Author automatically (~1µs)
});
```

### Deleting

```typescript
await E.transact(() => {
  const user = User.get(someId);
  if (user) user.delete();
});
```

### Model Utilities

```typescript
await E.transact(() => {
  const user = new User({name: "Bob", email: "bob@example.com", age: 25});

  user.validate();     // returns Error[]
  user.isValid();      // returns boolean
  user.getState();     // "created" | "loaded" | "lazy" | "deleted"
  user.getPrimaryKey(); // Uint8Array
  user.preventPersist(); // exclude from commit
});

// find() iterates all instances (or use range options)
await E.transact(() => {
  for (const user of User.find()) { ... }
  for (const user of User.find({reverse: true})) { ... }

  // {fetch: 'first'} returns a single instance or undefined
  const first = User.find({fetch: 'first'});

  // {fetch: 'single'} returns a single instance, or throws if there are none or more than one
  const only = User.find({fetch: 'single'});
});

// replaceInto: upsert by primary key
await E.transact(() => {
  User.replaceInto({id: existingId, name: "Updated Name", email: "new@example.com", age: 30});
});
```

### Batch Processing

For large datasets, `batchProcess` auto-commits in batches:

```typescript
await Product.batchProcess({ limitRows: 1000 }, (product) => {
  product.category = "archived";
});
// Commits every ~1 second or 4096 rows (configurable via limitSeconds, limitRows)
```

### Lazy Schema Migrations

When you change a model's schema, Edinburgh lazily migrates old records on access. You can provide a `static migrate(record)` function to transform old rows:

```typescript
const UserV2 = E.defineModel("User", class {
  id = E.field(E.identifier);
  name = E.field(E.string);
  role = E.field(E.string);  // newly added field

  static migrate(record: Record<string, any>) {
    record.role ??= record.name.indexOf("admin") >= 0 ? "admin" : "user"; // set role based on name for old records
  }
})
```

Edinburgh will lazily (re)run the `migrate` function on an instance whenever its implementation (the literal function code) has changed. For robustness, make sure that your `migrate` function...
- Is idempotent (meaning it can be safely run multiple times on the same row without changing the result after the first run), and
- Should perform *all* transformation steps starting from the oldest version that could possibly still be in the database. (See the next section.)

While lazy migration is convenient and often sufficient, in some cases you need migrations to happen immediately...

### Forced Schema Migrations

The `migrate-edinburgh` CLI tool will scan the entire database, pro-actively performing the following migrations:
- **Populate secondary indexes**: If you added or changed secondary indexes, it will build them. Until you do, the indexes will be empty (or only contain instances that have been saved since the index was created).
- **Migrate primary indexes**: In case you changed the primary key fields or field types (not recommended!) of a model, it will build the new primary index, as well as all secondary indexes (to point at the new primary keys). Until you do, all of your old data will appear to be missing! Note that this may fail on duplicates.
- **Remove orphaned indexes**: If you removed or changed an index, the stale data will be deleted from the database.
- **Rewrite primary data**: These are the types of migrations that would normally be done lazily on instance access. As there's usually not much benefit to doing this forcibly, and it can be very time-consuming (and generates a lot of I/O), this is *not* done by default. It may however be useful if you want to clean up the contents of your `migrate()` function, if you have control over all application deployments. Use the `--rewrite-data` flag to enable this.

```bash
npx migrate-edinburgh ./src/models.ts
```

Run `npx migrate-edinburgh` without arguments to see all options. You can also call `runMigration()` programmatically:

```typescript
import { runMigration } from "edinburgh";

const result = await runMigration({ tables: ["User"] });
console.log(result.secondaries);  // { User: 1500 }
```

### preCommit Hook

Compute derived fields before data is written:

```typescript
const Article = E.defineModel("Article", class {
  id = E.field(E.identifier);
  title = E.field(E.string);
  slug = E.field(E.string);

  preCommit() {
    this.slug = this.title.toLowerCase().replace(/\s+/g, "-");
  }
});
```

### Change Tracking

Monitor commits with `setOnSaveCallback`:

```typescript
E.setOnSaveCallback((commitId, items) => {
  for (const [instance, change] of items) {
    if (change === "created") { /* new record */ }
    else if (change === "deleted") { /* removed */ }
    else { /* change is an object with old values of modified fields */ }
  }
});
```

### Logging

Enable debug logging by setting the `EDINBURGH_LOG_LEVEL` environment variable (0–3). Higher numbers produce more verbose logs.

- 0: no logging (default)
- 1: model-level logs
- 2: + update logs
- 3: + read logs

### AI Integration

If you use Claude Code, GitHub Copilot or another AI agent that supports Skills, Edinburgh includes a `skill/` directory in its npm package that provides specialized knowledge to the AI about how to use the library effectively.

Symlink the skill into your project's `.claude/skills` directory:

```bash
mkdir -p .claude/skills
ln -s ../../node_modules/edinburgh/skill .claude/skills/edinburgh
```

## API Reference

The following is auto-generated from `src/edinburgh.ts`:

### [init](init.md) · function

Initialize the database with the specified directory path.
This function may be called multiple times with the same parameters. If it is not called before the first transact(),
the database will be automatically initialized with the default directory.

### [transact](transact.md) · function

Executes a function within a database transaction context.

### [setMaxRetryCount](setMaxRetryCount.md) · function

Set the maximum number of retries for a transaction in case of conflicts.
The default value is 6. Setting it to 0 will disable retries and cause transactions to fail immediately on conflict.

### [setOnSaveCallback](setOnSaveCallback.md) · function

Set a callback function to be called after a model is saved and committed.

### Model · class

**Type:** `typeof ModelBase`

### [ModelClass](ModelClass.md) · class

Runtime base constructor for model classes returned by `defineModel()`.

### [AnyModelClass](AnyModelClass.md) · type

A model constructor with its generic information erased.

### [ModelBase](ModelBase.md) · abstract class

Base class for all database models in the Edinburgh ORM.

### [ModelLookup](ModelLookup.md) · interface

### [defineModel](defineModel.md) · function

Register a model class with the Edinburgh ORM system.

### [deleteEverything](deleteEverything.md) · function

Delete every key/value entry in the database and reinitialize all registered models.

### [field](field.md) · function

Create a field definition for a model property.

### string · constant

Type wrapper instance for the string type.

**Value:** `TypeWrapper<string>`

### orderedString · constant

Type wrapper instance for the ordered string type, which is just like a string
except that it sorts lexicographically in the database (instead of by incrementing
length first), making it suitable for index fields that want lexicographic range
scans. Ordered strings are implemented as null-terminated UTF-8 strings, so they
may not contain null characters.

**Value:** `TypeWrapper<string>`

### number · constant

Type wrapper instance for the number type.

**Value:** `TypeWrapper<number>`

### dateTime · constant

Type wrapper instance for the date/time type. Stored without timezone info, rounded to whole seconds.

**Value:** `TypeWrapper<Date>`

### boolean · constant

Type wrapper instance for the boolean type.

**Value:** `TypeWrapper<boolean>`

### identifier · constant

Type wrapper instance for the identifier type.

**Value:** `TypeWrapper<string>`

### undef · constant

Type wrapper instance for the 'undefined' type.

**Value:** `TypeWrapper<undefined>`

### [opt](opt.md) · function

Create an optional type wrapper (allows undefined).

### [or](or.md) · function

Create a union type wrapper from multiple type choices.

### [array](array.md) · function

Create an array type wrapper with optional length constraints.

### [set](set.md) · function

Create a Set type wrapper with optional length constraints.

### [record](record.md) · function

Create a Record type wrapper for key-value objects with string or number keys.

### [object](object.md) · function

Create a fixed-shape object (struct) type wrapper. Unlike `record`, the
keys are part of the schema, so values are stored compactly in key order
without repeating the keys per record. Optional (`E.opt`) fields become
optional properties in the resulting type.

### [literal](literal.md) · function

Create a literal type wrapper for a constant value.

### [link](link.md) · function

Create a link type wrapper for model relationships.

### ObjectShape · type

A shape for `object`: a map of property name → field type (or literal).

**Type:** `Record<string, TypeWrapper<unknown> | BasicType>`

### [ObjectValue](ObjectValue.md) · type

The value type produced by `object` for a given shape. Keys whose field
type permits `undefined` (e.g. wrapped in `opt`) become optional; all
others are required.

### FieldValue · type

**Type:** `TYPE extends TypeWrapper<infer T>
        ? T
        : never`

### dump · function

**Signature:** `() => void`

### [FindOptions](FindOptions.md) · type

Range-query options accepted by `find()`, `findBy()`, `batchProcess()`, and `batchProcessBy()`.

### [IndexRangeIterator](IndexRangeIterator.md) · class

Iterator for range queries on indexes.
Handles common iteration logic for both primary and unique indexes.
Extends built-in Iterator to provide map/filter/reduce/toArray/etc.

### Change · type

**Type:** `Record<any, any> | "created" | "deleted"`

### [FieldConfig](FieldConfig.md) · interface

Configuration interface for model fields.

### [DatabaseError](DatabaseError.md) · constant

The DatabaseError class is used to represent errors that occur during database operations.
It extends the built-in Error class and has a machine readable error code string property.

### [runMigration](runMigration.md) · function

Run database migration: populate secondary indexes for old-version rows,
convert old primary indices, rewrite row data, and clean up orphaned indices.

### [MigrationOptions](MigrationOptions.md) · interface

#### migrationOptions.tables · member

### [MigrationResult](MigrationResult.md) · interface

#### migrationResult.secondaries · member

### [Transaction](Transaction.md) · interface

#### transaction.id · member

### txnStorage · constant

**Value:** `AsyncLocalStorage<Transaction>`

