Skip to content

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:

ts
// 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

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:

ts
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).proxy().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 methods from the selected mode remain available (where, map, toArrayAsync, and—for writable modes—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 like standard operations. Persist them with store.saveChangesAsync().

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:

ts
// 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

Released under the MIT License.