393 lines
10 KiB
Markdown
393 lines
10 KiB
Markdown
|
|
# 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:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
@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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
@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
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// 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 }
|
||
|
|
```
|
||
|
|
|
||
|
|
### Text indexes for search
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// 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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Always end with .exec()
|
||
|
|
const product = await this.model.findById(id).lean().exec();
|
||
|
|
```
|
||
|
|
|
||
|
|
### Cursor-based pagination (not offset)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
@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:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
@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:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
if (ingredient.quantity <= 0) {
|
||
|
|
throw new BadRequestException('Ingredient quantity must be positive');
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Backup Strategy (Docker/Self-Hosted)
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# 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
|
||
|
|
```
|