Extending Collections

You can extend a generated collection to add domain-specific helpers while preserving typing and change tracking. This allows you to encapsulate business logic and create intent-revealing APIs specific to your domain.

Quick Navigation

Basic Example

The simplest way to extend a collection is to add custom methods using the create() method:

import { DataStore } from "@routier/datastore";
import { MemoryPlugin } from "@routier/memory-plugin";
import { s } from "@routier/core/schema";

const userSchema = s
  .define("users", {
    id: s.string().key().identity(),
    email: s.string().distinct(),
    name: s.string(),
    status: s.string("active", "inactive", "pending"),
    createdAt: s.date().default(() => new Date()),
  })
  .compile();

class AppDataStore extends DataStore {
  users = this.collection(userSchema).create((Instance, ...args) => {
    return new (class extends Instance {
      constructor() {
        super(...args);
      }

      // Add a helper method that sets defaults
      async addWithDefaultsAsync(email: string, name: string) {
        return this.addAsync({
          email,
          name,
          status: "pending", // Default status
          createdAt: new Date(),
        });
      }

      // Add a method that combines query and update
      async activatePendingUsersAsync() {
        const pending = await this.where((u) => u.status === "pending").toArrayAsync();
        pending.forEach((user) => {
          user.status = "active";
        });
        return pending;
      }
    })();
  });

  constructor() {
    super(new MemoryPlugin("app"));
  }
}

const ctx = new AppDataStore();

// Use the custom method
await ctx.users.addWithDefaultsAsync("[email protected]", "User Name");

// Use the activation helper
await ctx.users.activatePendingUsersAsync();

// All base methods still work
const allUsers = await ctx.users.toArrayAsync();
await ctx.saveChangesAsync();

This example shows how to add helper methods that:

  • Combine query and create operations (findOrCreateByEmailAsync)
  • Combine querying and updates in a single operation (activatePendingUsersAsync)
  • Maintain full type safety and change tracking

Adding Helper Methods

You can add any number of custom methods that work with your collection’s data:

import { DataStore } from "@routier/datastore";
import { MemoryPlugin } from "@routier/memory-plugin";
import { s } from "@routier/core/schema";
import { InferCreateType, InferType } from "@routier/core/schema";

const productSchema = s
    .define("products", {
        id: s.string().key().identity(),
        name: s.string(),
        price: s.number(),
        stock: s.number(),
        category: s.string(),
    })
    .compile();

class AppDataStore extends DataStore {
    products = this.collection(productSchema).create((Instance, ...args) => {
        return new (class extends Instance {
            constructor() {
                super(...args);
            }

            // Add a method that combines multiple operations
            async restockAndUpdatePriceAsync(productId: string, newStock: number, newPrice: number) {
                const product = await this.firstOrUndefinedAsync((p) => p.id === productId);
                if (!product) {
                    throw new Error(`Product ${productId} not found`);
                }

                product.stock = newStock;
                product.price = newPrice;

                return product;
            }

            // Add a query helper with business logic
            async findLowStockAsync(threshold: number = 10): Promise<InferType<typeof productSchema>[]> {
                return this.where((p) => p.stock <= threshold).toArrayAsync();
            }

            // Add a bulk operation helper
            async bulkRestockAsync(items: Array<{ productId: string; quantity: number }>) {
                const products = await Promise.all(
                    items.map((item) => this.firstOrUndefinedAsync((p) => p.id === item.productId))
                );

                products.forEach((product, index) => {
                    if (product) {
                        product.stock += items[index].quantity;
                    }
                });

                return products.filter((p): p is InferType<typeof productSchema> => p !== undefined);
            }
        })();
    });

    constructor() {
        super(new MemoryPlugin("app"));
    }
}

const ctx = new AppDataStore();

// Use custom methods
await ctx.products.restockAndUpdatePriceAsync("prod-123", 50, 29.99);
const lowStock = await ctx.products.findLowStockAsync(20);
await ctx.products.bulkRestockAsync([
    { productId: "prod-123", quantity: 10 },
    { productId: "prod-456", quantity: 5 },
]);

await ctx.saveChangesAsync();

This example demonstrates:

  • Methods that combine multiple operations (restockAndUpdatePriceAsync)
  • Query helpers with parameters (findLowStockAsync)
  • Bulk operations that work with multiple entities (bulkRestockAsync)

Notes

  • The create((Instance, ...args) => new class extends Instance { ... }) pattern ensures the subclass receives the same constructor args and types as the base collection.
  • Always call super(...args) in the constructor to properly initialize the base collection.
  • Prefer adding cohesive, high-level helpers (e.g. addWithDefaults, findLowStock, bulkRestock) rather than simple wrappers.
  • All base collection methods remain available in your extended collection (where, map, toArrayAsync, saveChangesAsync, addAsync, removeAsync, attachments, etc.).
  • Extended collections maintain full type safety - TypeScript will infer types from your schema automatically.
  • Changes made through custom methods are tracked just like standard collection operations - you still need to call saveChangesAsync() to persist them.

When to Use

Extend collections when you want to:

  • Encapsulate repeated operations: If you find yourself writing the same query and update pattern multiple times, extract it into a custom method.
  • Provide domain-specific APIs: Create methods that express your business logic clearly (e.g. activatePendingUsers instead of querying and updating manually).
  • Combine multiple operations: Group related operations into single methods that make your code more readable and maintainable.
  • Add validation or business rules: Enforce domain constraints in custom methods before calling base collection methods.

Combining Operations

Extended collections work seamlessly with all base collection features. You can use queries, change tracking, and persistence within your custom methods:

// Custom method that uses queries
async getActiveUsersAsync() {
  return this.where((u) => u.status === "active").toArrayAsync();
}

// Custom method that combines query and update
async deactivateInactiveUsersAsync() {
  const inactive = await this.where((u) => u.lastLogin < cutoffDate).toArrayAsync();
  inactive.forEach((user) => {
    user.status = "inactive";
  });
  return inactive;
}

// Use the extended collection normally
await ctx.users.addAsync({ name: "New User", email: "[email protected]" });
await ctx.users.getActiveUsersAsync();
await ctx.saveChangesAsync(); // Persist all changes