> ## Documentation Index
> Fetch the complete documentation index at: https://woltz.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Change Tracking

> Track changes across entities and aggregate object graphs

## What is Change Tracking?

Change tracking is implemented by `BaseEntity`, so it is available to both
`Entity` and `Aggregate`. JavaScript Proxies observe property assignments and
collection mutations, while snapshots are used to calculate create, update, and
delete operations for nested entities.

In normal DDD usage, call `getChanges()` on the aggregate root so the returned
operations represent a single consistency and persistence boundary.

```typescript theme={null}
const order = new Order({
  id: Id.from("order-123"),
  status: "draft",
  items: [
    new OrderItem({ id: Id.from("item-1"), quantity: 2, unitPrice: 29.99 }),
  ],
});

// Make changes naturally
order.confirm();
order.items[0].updateQuantity(5);
order.addItem("prod-456", 1, 49.99);

// Get all changes with a single call
const changes = order.getChanges();

console.log(changes.hasUpdates()); // true - order status and item quantity changed
console.log(changes.hasCreates()); // true - new item added
```

<CardGroup cols={2}>
  <Card title="Efficient Persistence" icon="bolt">
    Persist detected updates instead of rewriting every child
  </Card>

  <Card title="Zero Boilerplate" icon="wand-magic-sparkles">
    Changes tracked automatically - no manual code needed
  </Card>

  <Card title="Correct Ordering" icon="arrow-down-1-9">
    Creates and deletes ordered by object-graph depth
  </Card>

  <Card title="Batch Operations" icon="layer-group">
    Group changes by entity type for optimized database operations
  </Card>
</CardGroup>

<Note>
  Construction establishes the initial baseline. `getChanges()` reports
  mutations made after construction or after the most recent
  `markAsClean()`/`markAsPersisted()` call. It does not emit a create operation
  for the root object itself; repositories normally use `isNew()` for that
  decision.
</Note>

## Basic Usage

### Getting Changes

```typescript theme={null}
const changes = order.getChanges();

// Check what changed
changes.hasCreates();  // Any new entities?
changes.hasUpdates();  // Any modified entities?
changes.hasDeletes();  // Any deleted entities?
changes.hasChanges();  // Any changes at all?
changes.isEmpty();     // No changes?

// Get count
changes.count;  // Total number of operations
```

### Accessing Operations

```typescript theme={null}
// Get all creates (ordered root → leaf)
const creates = changes.creates();
// CreateOperation[]

// Get all updates (no specific order)
const updates = changes.updates();
// UpdateOperation[]

// Get all deletes (ordered leaf → root)
const deletes = changes.deletes();
// DeleteOperation[]

// Iterate over all operations
for (const op of changes.operations()) {
  console.log(op.type, op.entity, op.depth);
}
```

### Filtering by Entity

Get changes for a specific entity type:

```typescript theme={null}
const itemChanges = changes.of("OrderItem");

// Access entities directly
itemChanges.creates;   // OrderItem[] - new items
itemChanges.deletes;   // OrderItem[] - deleted items
itemChanges.updates;   // Array<{ entity: OrderItem, changed: {...} }>

// Get IDs
itemChanges.createIds;  // string[]
itemChanges.updateIds;  // string[]
itemChanges.deleteIds;  // string[]

// Check for changes
itemChanges.hasCreates();
itemChanges.hasUpdates();
itemChanges.hasDeletes();

// Count
itemChanges.count;
```

### Filtering by Relation

Use `forRelation()` to select operations associated with a property in the
parent object:

```typescript theme={null}
const itemRelationChanges = changes.forRelation("items");

console.log(itemRelationChanges.hasChanges());
console.log(itemRelationChanges.getAffectedRelations()); // ["items"]
```

### Excluding Entities

Use `without()` when you need the inverse of `of()` — all changes **except** one or more entity types. It returns a **new** `AggregateChanges` instance; the original is unchanged.

```typescript theme={null}
const profileChanges = changes.of("Profile");

if (profileChanges.hasUpdates()) {
  await this.context.factoryProfile.update({
    where: { factoryId: factory.id.value },
    data: this.registry.mapFields("Profile", profileChanges.updates[0].changed),
  });
}

const remaining = changes.without("Profile");

if (!remaining.isEmpty()) {
  await super.onUpdate(remaining, factory);
}
```

Pass a single entity name or an array to exclude multiple types:

```typescript theme={null}
changes.without("Profile");
changes.without(["Profile", "Tag"]);
```

This is useful in custom `onUpdate` implementations when one entity needs manual persistence (nested writes, custom PK, etc.) and the rest should go through `PrismaBatchExecutor`.

### Resetting the Baseline

The component that successfully persists the object graph should reset its
tracking baseline:

```typescript theme={null}
await persistChanges(order.getChanges());
order.markAsClean();

// Now tracking starts fresh
const changes = order.getChanges();
console.log(changes.isEmpty()); // true
```

`markAsClean()` recursively clears changes in nested entities without changing
whether their IDs are new.

<Info>
  `PrismaRepository.save()` already calls `markAsPersisted()` after its mapper
  completes successfully. Do not add a second cleanup call when using that
  repository. For a custom repository or direct batch executor, reset the
  baseline only after persistence succeeds.
</Info>

### After First Save

When implementing a custom repository, call `markAsPersisted()` after the first
successful INSERT. It clears tracking and marks the root and nested IDs as not
new:

```typescript theme={null}
await insertAggregate(order);
order.markAsPersisted();

console.log(order.isNew()); // false
console.log(order.getChanges().isEmpty()); // true

order.updateStatus("confirmed");
await updateAggregate(order);
order.markAsClean();
```

<Info>
  `markAsPersisted()` changes ID state; it does not perform an INSERT by itself.
  The repository or mapper decides how `isNew()` affects persistence.
</Info>

## Batch Operations

Use `toBatchOperations()` to group changes by entity, relation, and parent
metadata:

<Info>
  `toBatchOperations()` is a data transformation only. It does not execute a
  transaction, map domain fields to database columns, or decide whether a
  relation is owned or referenced. A persistence adapter must handle those
  concerns.
</Info>

```typescript theme={null}
const batch = changes.toBatchOperations();

// Deletes are grouped and ordered leaf → root
for (const del of batch.deletes) {
  console.log(del.entity, del.ids, {
    parentId: del.parentId,
    parentEntity: del.parentEntity,
    relationField: del.relationField,
  });
}

// Creates are grouped and ordered parent → child
for (const create of batch.creates) {
  for (const item of create.items) {
    console.log(create.entity, item.data, {
      parentId: item.parentId,
      parentEntity: item.parentEntity,
      relationField: item.relationField,
    });
  }
}

// Updates are grouped by entity and are not depth-sorted
for (const update of batch.updates) {
  for (const item of update.items) {
    console.log(update.entity, item.id, item.changedFields);
  }
}
```

<Note>
  The ordering reflects the tracked object graph:

  * **Deletes**: Children first, then parents
  * **Creates**: Parents first, then children
  * **Updates**: Grouped by entity without depth ordering

  This ordering helps an adapter satisfy foreign keys, but it cannot guarantee
  database correctness by itself. Relationship configuration and transaction
  handling still belong to the adapter.
</Note>

## Operation Ordering

The library orders create and delete operations by their depth in the tracked
object graph.

Depth `0` is the object on which `getChanges()` is called. The tracker can emit
an update for that root, but it does not emit a create or delete for the root
itself.

### Deletes: Leaf → Root

Children must be deleted before parents to avoid FK violations:

```typescript theme={null}
// Given changes below an Order root:
// OrderItem
// └── ItemDiscount

const deletes = changes.deletes();
// Returns in order:
// 1. ItemDiscount (depth: 2)
// 2. OrderItem (depth: 1)
// The Order root itself is saved by its repository.
```

### Creates: Root → Leaf

Parents must be created before children so FKs can reference them:

```typescript theme={null}
const creates = changes.creates();
// Returns in order:
// 1. OrderItem (depth: 1)
// 2. ItemDiscount (depth: 2)
```

### Updates: Any Order

Updates are returned in detection order and grouped by entity in batch output:

```typescript theme={null}
const updates = changes.updates();
// No depth ordering guarantee
```

## Working with Collections

Collections of `Entity`/`Aggregate` instances produce item-level create,
update, and delete operations. Arrays containing only primitive values are
reported as an update to the property of their owning entity.

### Adding Items

```typescript theme={null}
const order = new Order({
  id: Id.from("order-123"),
  items: [],
});

order.addItem("prod-1", 2, 29.99);
order.addItem("prod-2", 1, 49.99);

const itemChanges = order.getChanges().of("OrderItem");
console.log(itemChanges.creates.length); // 2
```

### Removing Items

```typescript theme={null}
const order = new Order({
  id: Id.from("order-123"),
  items: [
    new OrderItem({ id: Id.from("item-1"), ... }),
    new OrderItem({ id: Id.from("item-2"), ... }),
  ],
});

order.removeItem(Id.from("item-1"));

const itemChanges = order.getChanges().of("OrderItem");
console.log(itemChanges.deleteIds); // ["item-1"]
```

### Updating Items

```typescript theme={null}
const order = new Order({
  id: Id.from("order-123"),
  items: [
    new OrderItem({ id: Id.from("item-1"), quantity: 2, ... }),
  ],
});

order.items[0].updateQuantity(5);

const itemChanges = order.getChanges().of("OrderItem");
console.log(itemChanges.updates[0].changed); // { quantity: 5 }
```

### Mixed Operations

```typescript theme={null}
// Create, update, and delete in the same transaction
order.addItem("prod-new", 1, 39.99);        // Create
order.items[0].updateQuantity(10);          // Update
order.removeItem(Id.from("item-old"));      // Delete

const itemChanges = order.getChanges().of("OrderItem");
console.log(itemChanges.creates.length); // 1
console.log(itemChanges.updates.length); // 1
console.log(itemChanges.deletes.length); // 1
```

## Working with Single Entities (1:1)

### Setting an Entity

```typescript theme={null}
const user = new User({
  id: Id.from("user-1"),
  address: null,
});

user.setAddress(new Address({
  street: "123 Main St",
  city: "New York",
}));

const addressChanges = user.getChanges().of("Address");
console.log(addressChanges.hasCreates()); // true
```

### Removing an Entity

```typescript theme={null}
const user = new User({
  id: Id.from("user-1"),
  address: new Address({ id: Id.from("addr-1"), ... }),
});

user.removeAddress();

const addressChanges = user.getChanges().of("Address");
console.log(addressChanges.deleteIds); // ["addr-1"]
```

### Updating an Entity

```typescript theme={null}
const user = new User({
  id: Id.from("user-1"),
  address: new Address({ id: Id.from("addr-1"), street: "Old St", ... }),
});

user.address.changeStreet("New Street");

const addressChanges = user.getChanges().of("Address");
console.log(addressChanges.updates[0].changed); // { street: "New Street" }
```

### Replacing an Entity

```typescript theme={null}
const user = new User({
  id: Id.from("user-1"),
  address: new Address({ id: Id.from("addr-1"), ... }),
});

// Replace with completely new address (different ID)
user.setAddress(new Address({
  street: "789 Pine Rd",
  city: "Boston",
}));

const addressChanges = user.getChanges().of("Address");
console.log(addressChanges.hasDeletes()); // true - old address
console.log(addressChanges.hasCreates()); // true - new address
```

## Deeply Nested Changes

Change tracking recursively follows nested `BaseEntity` instances and has no
fixed depth limit:

```typescript theme={null}
// Structure: User → Post → Comment → Like
const user = new User({
  id: Id.from("user-1"),
  posts: [
    new Post({
      id: Id.from("post-1"),
      comments: [
        new Comment({
          id: Id.from("comment-1"),
          likes: [],
        }),
      ],
    }),
  ],
});

// Add a like (depth 3)
user.posts[0].comments[0].addLike(new Like({ ... }));

// Update comment (depth 2)
user.posts[0].comments[0].changeText("Updated");

// Add new comment (depth 2)
user.posts[0].addComment(new Comment({ ... }));

const changes = user.getChanges();
console.log(changes.of("Comment").hasCreates()); // true
console.log(changes.of("Comment").hasUpdates()); // true
console.log(changes.of("Like").hasCreates());    // true
```

<Warning>
  Circular entity references are rejected during change comparison. Across
  aggregate boundaries, prefer storing another aggregate's `Id` instead of an
  object reference.
</Warning>

### Cascading Deletes

When a parent is removed from the tracked graph, its nested entities are also
reported as delete operations:

```typescript theme={null}
const user = new User({
  posts: [
    new Post({
      id: Id.from("post-1"),
      comments: [
        new Comment({ id: Id.from("comment-1"), likes: [...] }),
        new Comment({ id: Id.from("comment-2"), likes: [] }),
      ],
    }),
  ],
});

// Remove post from the tracked object graph
user.removePost(Id.from("post-1"));

const changes = user.getChanges();
console.log(changes.of("Post").deleteIds);    // ["post-1"]
console.log(changes.of("Comment").deleteIds); // ["comment-1", "comment-2"]
console.log(changes.of("Like").deleteIds);    // [...]

// Batch operations place deeper deletes first
const batch = changes.toBatchOperations();
// Order: Like → Comment → Post (leaf to root)
```

This is a cascade in the generated change set, not a database-level `ON DELETE
CASCADE`. The adapter still decides whether a relation is owned and should be
deleted or is a reference that should only be disconnected.

### Cascading Creates

When a parent with nested entities is added after the baseline, the parent and
its nested children are reported as creates:

```typescript theme={null}
user.addPost(new Post({
  title: "New Post",
  comments: [
    new Comment({
      text: "First comment",
      likes: [
        new Like({ ... }),
        new Like({ ... }),
      ],
    }),
  ],
}));

const changes = user.getChanges();
console.log(changes.of("Post").creates.length);    // 1
console.log(changes.of("Comment").creates.length); // 1
console.log(changes.of("Like").creates.length);    // 2

// Batch operations place shallower creates first
const batch = changes.toBatchOperations();
// Order: Post → Comment → Like (root to leaf)
```

Entities already present during construction are part of the baseline and are
not reported as creates merely because their IDs are new.

## Change History

`getHistory()` returns low-level writes observed by the entity's tracker:

```typescript theme={null}
order.updateStatus("confirmed");

const history = order.getHistory();
console.log(history[0]);
// {
//   path: "status",
//   previousValue: "draft",
//   currentValue: "confirmed",
//   timestamp: 1710000000000
// }
```

History entries are useful for debugging, but they are not the same as
`AggregateChanges`: multiple writes may collapse into one update operation,
and changes reverted back to the baseline may produce no persistence operation.

<Warning>
  Change history is in-memory diagnostic state, not a durable audit log.
  Persist explicit domain events or audit records when auditability is required.
</Warning>

## Collections with Entities

Use entities when collection items need independent create, update, and delete
operations. Value Objects remain appropriate for immutable values that are
persisted as part of their owner:

```typescript theme={null}
class TagReference extends Entity<{ id: Id; tagId: string; name: string }> {
  get tagId() { return this.props.tagId; }
  get name() { return this.props.name; }
}

class Like extends Entity<{
  id: Id;
  postId: string;
  userId: string;
  createdAt: Date;
}> {
  get postId() { return this.props.postId; }
  get userId() { return this.props.userId; }
}
```

### How Entity Tracking Works

Entities are tracked by their ID:

```typescript theme={null}
const user = new User({
  id: Id.from("user-1"),
  tags: [
    new TagReference({
      id: Id.from("tag-ref-1"),
      tagId: "tag-1",
      name: "JavaScript"
    }),
    new TagReference({
      id: Id.from("tag-ref-2"),
      tagId: "tag-2",
      name: "TypeScript"
    }),
  ],
});

// Remove a tag
user.removeTag("tag-1");

// Add a new tag
user.addTag(new TagReference({
  id: new Id(),
  tagId: "tag-3",
  name: "Node.js"
}));

const changes = user.getChanges();
const tagChanges = changes.of("TagReference");

console.log(tagChanges.deletes.length); // 1
console.log(tagChanges.creates.length); // 1
```

### Nested Entities Example

```typescript theme={null}
const comment = new Comment({
  id: Id.from("comment-1"),
  text: "Great post!",
  likes: [
    new Like({
      id: new Id(),
      postId: "post-1",
      userId: "user-1",
      createdAt: new Date()
    }),
    new Like({
      id: new Id(),
      postId: "post-1",
      userId: "user-2",
      createdAt: new Date()
    }),
  ],
});

// Remove a specific like
comment.removeLike("post-1", "user-2");

const changes = comment.getChanges();
const likeChanges = changes.of("Like");

console.log(likeChanges.deletes.length); // 1
```

## Type-Safe Changes

For better TypeScript support, define an entity map:

```typescript theme={null}
type OrderEntities = {
  Order: Order;
  OrderItem: OrderItem;
  ItemDiscount: ItemDiscount;
  Address: Address;
};

// Get typed changes
const changes = order.getChanges<OrderEntities>();

// Now 'of' has autocomplete
const itemChanges = changes.of("OrderItem");
//                              ^? "Order" | "OrderItem" | "ItemDiscount" | "Address"

// Returns are properly typed
itemChanges.creates.forEach((item) => {
  console.log(item.quantity); // item is OrderItem
});
```

### Helper Method Pattern

```typescript theme={null}
class Order extends Aggregate<OrderProps> {
  getTypedChanges() {
    type Entities = {
      Order: Order;
      OrderItem: OrderItem;
      ItemDiscount: ItemDiscount;
    };
    return this.getChanges<Entities>();
  }
}

// Usage
const changes = order.getTypedChanges();
const items = changes.of("OrderItem"); // Fully typed!
```

<Tip>
  We strongly recommend defining a `getTypedChanges()` helper method directly in your Aggregate class. This provides a cleaner API and avoids repeating the entity map every time:

  ```typescript theme={null}
  class Order extends Aggregate<OrderProps> {
    getTypedChanges() {
      interface Entities {
        OrderItem: OrderItem;
        ItemDiscount: ItemDiscount;
      }
      return this.getChanges<Entities>();
    }
  }

  // Usage - clean and type-safe
  const changes = order.getTypedChanges();
  const items = changes.of("OrderItem"); // Autocomplete works!
  ```
</Tip>

<Note>
  Entity-map keys must match the runtime class names stored in operations. For
  example, an `Address` instance produces the entity name `"Address"`, not the
  property name `"shippingAddress"`.
</Note>

## Persistence Example with Prisma

`PrismaToPersistence` uses `PrismaBatchExecutor` for updates by default. The
executor understands relation metadata, schema mappings, owned collections, and
reference collections, while `PrismaRepository.save()` resets the aggregate
with `markAsPersisted()` after persistence:

```typescript theme={null}
class OrderService {
  constructor(private readonly orderRepo: OrderRepository) {}

  async confirmOrder(orderId: string): Promise<void> {
    const order = await this.orderRepo.findById(orderId);
    if (!order) throw new Error("Order not found");

    order.confirm();

    const changes = order.getTypedChanges();
    if (changes.isEmpty()) return;

    await this.orderRepo.save(order);

    console.log(order.getChanges().isEmpty()); // true
  }
}
```

Override `PrismaToPersistence.onUpdate()` only when part of the graph needs
custom persistence. Use `without()` to pass the remaining operations to the
default executor.

## API Reference

### BaseEntity Tracking

These methods are available on both `Entity` and `Aggregate`:

| Method                     | Returns                        | Description                                                  |
| -------------------------- | ------------------------------ | ------------------------------------------------------------ |
| `getChanges<TEntityMap>()` | `AggregateChanges<TEntityMap>` | Calculate operations since the current baseline              |
| `getHistory()`             | `HistoryEntry[]`               | Copy of low-level writes observed by the tracker             |
| `markAsClean()`            | `void`                         | Recursively establish a new baseline                         |
| `markAsPersisted()`        | `void`                         | Recursively establish a new baseline and mark IDs as not new |

### AggregateChanges

| Method                        | Returns             | Description                                      |
| ----------------------------- | ------------------- | ------------------------------------------------ |
| `creates()`                   | `CreateOperation[]` | All creates, ordered root → leaf                 |
| `updates()`                   | `UpdateOperation[]` | All updates                                      |
| `deletes()`                   | `DeleteOperation[]` | All deletes, ordered leaf → root                 |
| `of(entity)`                  | `EntityChanges<T>`  | Filter changes by entity name                    |
| `forRelation(relation)`       | `AggregateChanges`  | New instance filtered by relation field          |
| `without(entity \| entities)` | `AggregateChanges`  | New instance excluding the given entity/entities |
| `toBatchOperations()`         | `BatchOperations`   | Group changes for bulk operations                |
| `hasCreates()`                | `boolean`           | Has any creates                                  |
| `hasUpdates()`                | `boolean`           | Has any updates                                  |
| `hasDeletes()`                | `boolean`           | Has any deletes                                  |
| `hasChanges()`                | `boolean`           | Has any operations                               |
| `isEmpty()`                   | `boolean`           | No operations                                    |
| `getAffectedEntities()`       | `string[]`          | List of entity names with changes                |
| `getAffectedRelations()`      | `string[]`          | List of relation fields with changes             |
| `operations()`                | `Generator`         | Iterate all operations in order                  |
| `toArray()`                   | `Operation[]`       | All operations as array                          |
| `rawOperations`               | `Operation[]`       | Copy of operations without ordering              |
| `clone()`                     | `AggregateChanges`  | Independent copy of the operation list           |
| `clear()`                     | `void`              | Clear this result object's operations            |
| `count`                       | `number`            | Total number of operations                       |

<Warning>
  `AggregateChanges.clear()` only mutates that result object. It does not reset
  the entity tracker; a later `entity.getChanges()` will calculate the changes
  again. Use `entity.markAsClean()` to establish a new baseline.
</Warning>

### EntityChanges

| Property/Method | Returns                               | Description                         |
| --------------- | ------------------------------------- | ----------------------------------- |
| `creates`       | `T[]`                                 | Created entities                    |
| `updates`       | `Array<{entity: T, changed: Record}>` | Updated entities with changes       |
| `deletes`       | `T[]`                                 | Deleted entities                    |
| `createIds`     | `string[]`                            | IDs of created entities             |
| `updateIds`     | `string[]`                            | IDs of updated entities             |
| `deleteIds`     | `string[]`                            | IDs of deleted entities             |
| `hasCreates()`  | `boolean`                             | Has any creates                     |
| `hasUpdates()`  | `boolean`                             | Has any updates                     |
| `hasDeletes()`  | `boolean`                             | Has any deletes                     |
| `hasChanges()`  | `boolean`                             | Has any operations                  |
| `isEmpty()`     | `boolean`                             | No operations                       |
| `rawOperations` | `Operation<T>[]`                      | Copy of the filtered raw operations |
| `count`         | `number`                              | Total operations                    |

### BatchOperations

```typescript theme={null}
interface BatchOperations {
  deletes: Array<{
    entity: string;
    depth: number;
    ids: string[];
    parentId?: string;
    parentEntity?: string;
    relationField?: string;
    items?: Array<{
      id: string;
      relationField?: string;
    }>;
  }>;

  creates: Array<{
    entity: string;
    depth: number;
    parentEntity?: string;
    relationField?: string;
    items: Array<{
      data: any;
      parentId?: string;
      parentEntity?: string;
      relationField?: string;
    }>;
  }>;

  updates: Array<{
    entity: string;
    items: Array<{
      id: string;
      changedFields: Record<string, any>;
    }>;
  }>;
}
```
