MeshiTrack/docs/instructions/mongodb.md

10 KiB

MongoDB & Mongoose Best Practices — MeshiTrack

Instruction file for database design and Mongoose usage across the project.

Schema Design Principles

Embed when possible, reference when necessary

MongoDB favors denormalization. Use this decision tree:

  • Embed (subdocument) when:

    • Data belongs exclusively to the parent (e.g., NutritionInfo inside Product)
    • Data is always read together with the parent
    • The embedded array is bounded and small (< 100 items)
  • Reference (ObjectId) when:

    • Data is shared across multiple documents (e.g., Product referenced by Recipe, PantryItem, ShoppingItem)
    • The referenced document is large or changes independently
    • You need to query the referenced document on its own

MeshiTrack schema strategy

Schema Embedded Data Referenced Data
Product nutrition: NutritionInfo (embed)
Recipe ingredients[], steps[] (embed) ingredients[].productId (ref)
totalNutrition, perServingNutrition
PantryItem freshnessEstimate (embed) productId (ref), storeId (ref)
ShoppingList items[] (embed) items[].productId (ref)
MealPlan days[].meals[] (embed) meals[].recipeId (ref)
PriceRecord productId (ref), storeId (ref)

Denormalize names for display

Store productName alongside productId so list views don't require joins:

@Prop({ type: mongoose.Schema.Types.ObjectId, ref: 'Product', required: true })
productId: mongoose.Types.ObjectId;

@Prop({ required: true })
productName: string;  // Denormalized from Product.name

Update denormalized names when the source changes (background job).

Mongoose Schema Definitions

Use NestJS decorators for schema definitions

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';

export type ProductDocument = HydratedDocument<Product>;

@Schema({
  timestamps: true, // Auto-manages createdAt, updatedAt
  collection: 'products', // Explicit collection name
  toJSON: { virtuals: true }, // Include virtuals in JSON output
})
export class Product {
  @Prop({ required: true, index: true })
  householdId: string;

  @Prop({ required: true, trim: true })
  name: string;

  @Prop({ trim: true })
  brand?: string;

  @Prop({ unique: false, sparse: true })
  barcode?: string;

  @Prop({ required: true, enum: ProductCategory })
  category: string;

  @Prop({ type: NutritionInfoSchema })
  nutrition: NutritionInfo;

  @Prop([String])
  tags: string[];

  @Prop()
  deletedAt?: Date; // Soft delete

  @Prop({ required: true })
  createdBy: string;
}

export const ProductSchema = SchemaFactory.createForClass(Product);

Define subdocument schemas separately

@Schema({ _id: false }) // No separate _id for embedded subdocuments
export class NutritionInfo {
  @Prop({ required: true, min: 0 })
  calories: number;

  @Prop({ required: true, min: 0 })
  protein: number;

  @Prop({ required: true, min: 0 })
  carbs: number;

  @Prop({ required: true, min: 0 })
  fat: number;

  @Prop({ min: 0 })
  fiber?: number;

  @Prop({ min: 0 })
  sugar?: number;

  @Prop({ min: 0 })
  sodium?: number;
}

export const NutritionInfoSchema = SchemaFactory.createForClass(NutritionInfo);

Indexing Strategy

Every query pattern needs an index

Design indexes based on the queries your app actually runs, not just the schema structure.

Compound indexes: put equality fields first, range/sort fields last

// Good: householdId (equality) + status (equality) + urgency (sort/filter)
{ householdId: 1, status: 1, 'freshnessEstimate.urgency': 1 }

// Bad: sorting field first
{ 'freshnessEstimate.urgency': 1, householdId: 1, status: 1 }
// Define after schema creation
ProductSchema.index(
  { name: 'text', brand: 'text', tags: 'text' },
  { weights: { name: 10, brand: 5, tags: 3 } }, // Name matches rank higher
);

Only one text index per collection. If you need multiple text search patterns, use Atlas Search or a separate search service.

Required indexes per collection

// Products
{ householdId: 1, category: 1 }
{ householdId: 1, barcode: 1 }
{ name: 'text', brand: 'text', tags: 'text' }

// Recipes
{ householdId: 1 }
{ householdId: 1, 'ingredients.productId': 1 }
{ name: 'text', tags: 'text', cuisine: 'text' }

// PantryItems
{ householdId: 1, status: 1, 'freshnessEstimate.estimatedExpiryDate': 1 }
{ householdId: 1, storageLocation: 1, status: 1 }
{ householdId: 1, productId: 1, status: 1 }

// PriceRecords
{ householdId: 1, productId: 1, storeId: 1, date: -1 }
{ householdId: 1, productId: 1, date: -1 }

// ShoppingLists
{ householdId: 1, status: 1 }

// FreshnessRules
{ category: 1, storageLocation: 1 }

Register indexes in schema files

// After schema class definition
ProductSchema.index({ householdId: 1, category: 1 });
ProductSchema.index({ householdId: 1, barcode: 1 }, { sparse: true });
ProductSchema.index(
  { name: 'text', brand: 'text', tags: 'text' },
  { weights: { name: 10, brand: 5, tags: 3 } },
);

Query Best Practices

Always filter by householdId first

Every single data query MUST include householdId. Enforce this in the repository layer:

// Every repository method takes householdId as the first parameter
async findAll(householdId: string, filter: any = {}): Promise<Product[]> {
  return this.model
    .find({ householdId, deletedAt: null, ...filter })
    .lean()
    .exec();
}

Use .lean() for read operations

// Returns plain JS objects — 2-5x faster than hydrated documents
const products = await this.model.find(filter).lean().exec();

Only skip .lean() when you need Mongoose document methods (.save(), virtuals, middleware).

Use .exec() on all queries

// Always end with .exec()
const product = await this.model.findById(id).lean().exec();

Cursor-based pagination (not offset)

async findPaginated(
  householdId: string,
  cursor: string | null,
  limit: number = 20,
): Promise<{ data: Product[]; nextCursor: string | null }> {
  const filter: any = { householdId, deletedAt: null };

  if (cursor) {
    filter._id = { $gt: new Types.ObjectId(cursor) };
  }

  const docs = await this.model
    .find(filter)
    .sort({ _id: 1 })
    .limit(limit + 1)           // Fetch one extra to determine hasMore
    .lean()
    .exec();

  const hasMore = docs.length > limit;
  const data = hasMore ? docs.slice(0, limit) : docs;
  const nextCursor = hasMore ? data[data.length - 1]._id.toString() : null;

  return { data, nextCursor };
}

Use aggregation pipelines for analytics

// Example: Waste stats
async getWasteStats(householdId: string, startDate: Date, endDate: Date) {
  return this.model.aggregate([
    {
      $match: {
        householdId,
        updatedAt: { $gte: startDate, $lte: endDate },
        status: { $in: ['consumed', 'discarded'] },
      },
    },
    {
      $group: {
        _id: '$status',
        count: { $sum: 1 },
      },
    },
  ]).exec();
}

Soft Deletes

Use deletedAt field, filter in repository

@Prop({ type: Date, default: null })
deletedAt: Date | null;

// Repository always filters
async findAll(householdId: string): Promise<Product[]> {
  return this.model.find({ householdId, deletedAt: null }).lean().exec();
}

// Soft delete
async softDelete(id: string, householdId: string): Promise<void> {
  await this.model.updateOne(
    { _id: id, householdId },
    { $set: { deletedAt: new Date() } },
  ).exec();
}

Transactions

Only use transactions when updating multiple documents that must be atomic:

async transferItem(fromPantry: string, toRecipe: string): Promise<void> {
  const session = await this.connection.startSession();
  try {
    session.startTransaction();
    // ... multiple operations with { session }
    await session.commitTransaction();
  } catch (error) {
    await session.abortTransaction();
    throw error;
  } finally {
    session.endSession();
  }
}

Note: MongoDB transactions require a replica set. For local development, use a single-node replica set in Docker.

Connection Management

Configure connection in AppModule

MongooseModule.forRootAsync({
  imports: [ConfigModule],
  useFactory: (config: ConfigService) => ({
    uri: config.get<string>('MONGODB_URI'),
    maxPoolSize: 10, // Connection pool size
    serverSelectionTimeoutMS: 5000, // Fail fast on connection issues
    socketTimeoutMS: 45000,
    retryWrites: true,
  }),
  inject: [ConfigService],
});

Monitor connection events

MongooseModule.forRootAsync({
  useFactory: () => ({
    uri: process.env.MONGODB_URI,
    onConnectionCreate: (connection) => {
      connection.on('connected', () => console.log('MongoDB connected'));
      connection.on('disconnected', () => console.warn('MongoDB disconnected'));
      connection.on('error', (err) => console.error('MongoDB error', err));
      return connection;
    },
  }),
});

Data Validation

Schema-level validation for data integrity

@Prop({
  required: true,
  min: 0,
  max: 99999,
  validate: {
    validator: (v: number) => v >= 0,
    message: 'Calories cannot be negative',
  },
})
calories: number;

Application-level validation for business rules

Don't rely solely on Mongoose validation. Validate in the service layer with meaningful error messages:

if (ingredient.quantity <= 0) {
  throw new BadRequestException('Ingredient quantity must be positive');
}

Backup Strategy (Docker/Self-Hosted)

# Backup: run inside the mongodb container or from host
mongodump --uri="mongodb://meshitrack:password@localhost:27017/meshitrack?authSource=admin" --out=/backup/$(date +%Y%m%d)

# Restore
mongorestore --uri="mongodb://meshitrack:password@localhost:27017/meshitrack?authSource=admin" /backup/20260325

# Automate with cron on the host or a Docker sidecar